Add weekly reports, cleanup, and dashboard UI

Introduce weekly summary reports and a cleanup job, enhance dashboard UI, and adjust telemetry/build settings.

- Add REPO_SOURCE to misc/api.func and include repo_source in telemetry payloads.
- Implement weekly report generation/scheduling in alerts.go: new data types, HTML/plain templates, scheduler, SendWeeklyReport/TestWeeklyReport, and email/HTML helpers.
- Add Cleaner (misc/data/cleanup.go) to detect and mark stuck installations as 'unknown' with scheduling and manual trigger APIs.
- Enhance dashboard backend/frontend (misc/data/dashboard.go): optional days filter (allow 'All'), increase fetch page size, simplify fetchRecords, add quick filter buttons, detail & health modals, improved styles and chart options, and client-side record detail view.
- Update Dockerfile (misc/data/Dockerfile): rename binaries to telemetry-service and build migrate from ./migration/migrate.go; copy adjusted in final image.
- Add migration tooling (misc/data/migration/migrate.sh and migration.go) and other small service changes.

These changes add operational reporting and cleanup capabilities, improve observability and UX of the dashboard, and align build and telemetry identifiers for the service.
This commit is contained in:
CanbiZ (MickLesk) 2026-02-11 12:19:30 +01:00
parent 85888cd934
commit d32b00ff31
11 changed files with 2047 additions and 110 deletions

View File

@ -37,6 +37,10 @@ TELEMETRY_URL="https://telemetry.community-scripts.org/telemetry"
# Timeout for telemetry requests (seconds)
TELEMETRY_TIMEOUT=5
# Repository source identifier (auto-transformed by CI on promotion to ProxmoxVE)
# DO NOT CHANGE - this is used by the telemetry service to route data to the correct collection
REPO_SOURCE="community-scripts/ProxmoxVED"
# ==============================================================================
# SECTION 1: ERROR CODE DESCRIPTIONS
# ==============================================================================
@ -350,7 +354,8 @@ post_to_api() {
"gpu_vendor": "${gpu_vendor}",
"gpu_model": "${gpu_model}",
"gpu_passthrough": "${gpu_passthrough}",
"ram_speed": "${ram_speed}"
"ram_speed": "${ram_speed}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)
@ -421,7 +426,8 @@ post_to_api_vm() {
"os_type": "${var_os:-}",
"os_version": "${var_version:-}",
"pve_version": "${pve_version}",
"method": "${METHOD:-default}"
"method": "${METHOD:-default}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)
@ -541,7 +547,8 @@ post_update_to_api() {
"gpu_vendor": "${gpu_vendor}",
"gpu_model": "${gpu_model}",
"gpu_passthrough": "${gpu_passthrough}",
"ram_speed": "${ram_speed}"
"ram_speed": "${ram_speed}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)
@ -671,7 +678,8 @@ post_tool_to_api() {
"error": "${error}",
"error_category": "${error_category}",
"install_duration": ${duration:-0},
"pve_version": "${pve_version}"
"pve_version": "${pve_version}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)
@ -734,7 +742,8 @@ post_addon_to_api() {
"error_category": "${error_category}",
"install_duration": ${duration:-0},
"os_type": "${os_type}",
"os_version": "${os_version}"
"os_version": "${os_version}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)
@ -820,7 +829,8 @@ post_update_to_api_extended() {
"error_category": "${error_category}",
"install_duration": ${duration:-0},
"gpu_vendor": "${gpu_vendor}",
"gpu_passthrough": "${gpu_passthrough}"
"gpu_passthrough": "${gpu_passthrough}",
"repo_source": "${REPO_SOURCE}"
}
EOF
)

View File

@ -3,13 +3,13 @@ WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download 2>/dev/null || true
COPY . .
RUN go build -trimpath -ldflags "-s -w" -o /out/telemetry-ingest .
RUN go build -trimpath -ldflags "-s -w" -o /out/migrate migrate.go
RUN go build -trimpath -ldflags "-s -w" -o /out/telemetry-service .
RUN go build -trimpath -ldflags "-s -w" -o /out/migrate ./migration/migrate.go
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=build /out/telemetry-ingest /app/telemetry-ingest
COPY --from=build /out/telemetry-service /app/telemetry-service
COPY --from=build /out/migrate /app/migrate
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh /app/migrate

View File

@ -25,16 +25,54 @@ type AlertConfig struct {
FailureThreshold float64 // Alert when failure rate exceeds this (e.g., 20.0 = 20%)
CheckInterval time.Duration // How often to check
Cooldown time.Duration // Minimum time between alerts
// Weekly Report settings
WeeklyReportEnabled bool // Enable weekly summary reports
WeeklyReportDay time.Weekday // Day to send report (0=Sunday, 1=Monday, etc.)
WeeklyReportHour int // Hour to send report (0-23)
}
// WeeklyReportData contains aggregated weekly statistics
type WeeklyReportData struct {
CalendarWeek int
Year int
StartDate time.Time
EndDate time.Time
TotalInstalls int
SuccessCount int
FailedCount int
SuccessRate float64
TopApps []AppStat
TopFailedApps []AppStat
ComparedToPrev WeekComparison
OsDistribution map[string]int
TypeDistribution map[string]int
}
// AppStat represents statistics for a single app
type AppStat struct {
Name string
Total int
Failed int
FailureRate float64
}
// WeekComparison shows changes compared to previous week
type WeekComparison struct {
InstallsChange int // Difference in total installs
InstallsPercent float64 // Percentage change
FailRateChange float64 // Change in failure rate (percentage points)
}
// Alerter handles alerting functionality
type Alerter struct {
cfg AlertConfig
lastAlertAt time.Time
mu sync.Mutex
pb *PBClient
lastStats alertStats
alertHistory []AlertEvent
cfg AlertConfig
lastAlertAt time.Time
lastWeeklyReport time.Time
mu sync.Mutex
pb *PBClient
lastStats alertStats
alertHistory []AlertEvent
}
type alertStats struct {
@ -74,6 +112,12 @@ func (a *Alerter) Start() {
go a.monitorLoop()
log.Printf("INFO: alert monitoring started (threshold: %.1f%%, interval: %v)", a.cfg.FailureThreshold, a.cfg.CheckInterval)
// Start weekly report scheduler if enabled
if a.cfg.WeeklyReportEnabled {
go a.weeklyReportLoop()
log.Printf("INFO: weekly report scheduler started (day: %s, hour: %02d:00)", a.cfg.WeeklyReportDay, a.cfg.WeeklyReportHour)
}
}
func (a *Alerter) monitorLoop() {
@ -162,13 +206,21 @@ This is an automated alert from the telemetry service.
}
func (a *Alerter) sendEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/plain")
}
func (a *Alerter) sendHTMLEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/html")
}
func (a *Alerter) sendEmailWithType(subject, body, contentType string) error {
// Build message
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", a.cfg.SMTPFrom))
msg.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(a.cfg.SMTPTo, ", ")))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: %s; charset=UTF-8\r\n", contentType))
msg.WriteString("\r\n")
msg.WriteString(body)
@ -264,4 +316,538 @@ This is an automated test message.
// Helper for timeout context
func newTimeoutContext(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// weeklyReportLoop checks periodically if it's time to send the weekly report
func (a *Alerter) weeklyReportLoop() {
// Check every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
a.checkAndSendWeeklyReport()
}
}
// checkAndSendWeeklyReport sends the weekly report if it's the right time
func (a *Alerter) checkAndSendWeeklyReport() {
now := time.Now()
// Check if it's the right day and hour
if now.Weekday() != a.cfg.WeeklyReportDay || now.Hour() != a.cfg.WeeklyReportHour {
return
}
a.mu.Lock()
// Check if we already sent a report this week
_, lastWeek := a.lastWeeklyReport.ISOWeek()
_, currentWeek := now.ISOWeek()
if a.lastWeeklyReport.Year() == now.Year() && lastWeek == currentWeek {
a.mu.Unlock()
return
}
a.mu.Unlock()
// Send the weekly report
if err := a.SendWeeklyReport(); err != nil {
log.Printf("ERROR: failed to send weekly report: %v", err)
}
}
// SendWeeklyReport generates and sends the weekly summary email
func (a *Alerter) SendWeeklyReport() error {
if !a.cfg.Enabled || a.cfg.SMTPHost == "" {
return fmt.Errorf("alerting not configured")
}
ctx, cancel := newTimeoutContext(30 * time.Second)
defer cancel()
// Get data for the past week
reportData, err := a.fetchWeeklyReportData(ctx)
if err != nil {
return fmt.Errorf("failed to fetch weekly data: %w", err)
}
// Generate email content
subject := fmt.Sprintf("[ProxmoxVED] Weekly Report - Week %d, %d", reportData.CalendarWeek, reportData.Year)
body := a.generateWeeklyReportHTML(reportData)
if err := a.sendHTMLEmail(subject, body); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
a.mu.Lock()
a.lastWeeklyReport = time.Now()
a.alertHistory = append(a.alertHistory, AlertEvent{
Timestamp: time.Now(),
Type: "weekly_report",
Message: fmt.Sprintf("Weekly report KW %d/%d sent", reportData.CalendarWeek, reportData.Year),
})
a.mu.Unlock()
log.Printf("INFO: weekly report KW %d/%d sent successfully", reportData.CalendarWeek, reportData.Year)
return nil
}
// fetchWeeklyReportData collects data for the weekly report
func (a *Alerter) fetchWeeklyReportData(ctx context.Context) (*WeeklyReportData, error) {
// Calculate the previous week's date range (Mon-Sun)
now := time.Now()
// Find last Monday
daysToLastMonday := int(now.Weekday() - time.Monday)
if daysToLastMonday < 0 {
daysToLastMonday += 7
}
// Go back to the Monday of LAST week
lastMonday := now.AddDate(0, 0, -daysToLastMonday-7)
lastMonday = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
lastSunday := lastMonday.AddDate(0, 0, 6)
lastSunday = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
// Get calendar week
year, week := lastMonday.ISOWeek()
// Fetch current week's data (7 days)
currentData, err := a.pb.FetchDashboardData(ctx, 7)
if err != nil {
return nil, fmt.Errorf("failed to fetch current week data: %w", err)
}
// Fetch previous week's data for comparison (14 days, we'll compare)
prevData, err := a.pb.FetchDashboardData(ctx, 14)
if err != nil {
// Non-fatal, just log
log.Printf("WARN: could not fetch previous week data: %v", err)
prevData = nil
}
// Build report data
report := &WeeklyReportData{
CalendarWeek: week,
Year: year,
StartDate: lastMonday,
EndDate: lastSunday,
TotalInstalls: currentData.TotalInstalls,
SuccessCount: currentData.SuccessCount,
FailedCount: currentData.FailedCount,
OsDistribution: make(map[string]int),
TypeDistribution: make(map[string]int),
}
// Calculate success rate
if report.TotalInstalls > 0 {
report.SuccessRate = float64(report.SuccessCount) / float64(report.TotalInstalls) * 100
}
// Top 5 installed apps
for i, app := range currentData.TopApps {
if i >= 5 {
break
}
report.TopApps = append(report.TopApps, AppStat{
Name: app.App,
Total: app.Count,
})
}
// Top 5 failed apps
for i, app := range currentData.FailedApps {
if i >= 5 {
break
}
report.TopFailedApps = append(report.TopFailedApps, AppStat{
Name: app.App,
Total: app.TotalCount,
Failed: app.FailedCount,
FailureRate: app.FailureRate,
})
}
// OS distribution
for _, os := range currentData.OsDistribution {
report.OsDistribution[os.Os] = os.Count
}
// Type distribution (LXC vs VM)
for _, t := range currentData.TypeStats {
report.TypeDistribution[t.Type] = t.Count
}
// Calculate comparison to previous week
if prevData != nil {
// Previous week stats (subtract current from 14-day total)
prevInstalls := prevData.TotalInstalls - currentData.TotalInstalls
prevFailed := prevData.FailedCount - currentData.FailedCount
prevSuccess := prevData.SuccessCount - currentData.SuccessCount
if prevInstalls > 0 {
prevFailRate := float64(prevFailed) / float64(prevInstalls) * 100
currentFailRate := 100 - report.SuccessRate
report.ComparedToPrev.InstallsChange = report.TotalInstalls - prevInstalls
if prevInstalls > 0 {
report.ComparedToPrev.InstallsPercent = float64(report.TotalInstalls-prevInstalls) / float64(prevInstalls) * 100
}
report.ComparedToPrev.FailRateChange = currentFailRate - prevFailRate
_ = prevSuccess // suppress unused warning
}
}
return report, nil
}
// generateWeeklyReportHTML creates the HTML email body for the weekly report
func (a *Alerter) generateWeeklyReportHTML(data *WeeklyReportData) string {
var b strings.Builder
// HTML Email Template
b.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f6f9fc;padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.07);">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:32px 40px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:600;">📊 Weekly Telemetry Report</h1>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.85);font-size:14px;">ProxmoxVE Helper Scripts</p>
</td>
</tr>
<!-- Week Info -->
<tr>
<td style="padding:24px 40px 0;">
<table width="100%" style="background:#f8fafc;border-radius:8px;padding:16px;">
<tr>
<td style="padding:12px 16px;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Calendar Week</span><br>
<span style="color:#1e293b;font-size:20px;font-weight:600;">Week `)
b.WriteString(fmt.Sprintf("%d, %d", data.CalendarWeek, data.Year))
b.WriteString(`</span>
</td>
<td style="padding:12px 16px;text-align:right;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Period</span><br>
<span style="color:#1e293b;font-size:14px;">`)
b.WriteString(fmt.Sprintf("%s %s", data.StartDate.Format("Jan 02"), data.EndDate.Format("Jan 02, 2006")))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Stats Grid -->
<tr>
<td style="padding:24px 40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.TotalInstalls))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Total</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.SuccessCount))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Successful</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#dc2626;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.FailedCount))
b.WriteString(`</div>
<div style="color:#991b1b;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Failed</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#2563eb;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%.1f%%", data.SuccessRate))
b.WriteString(`</div>
<div style="color:#1e40af;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Success Rate</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
`)
// Week comparison
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
installIcon := "📈"
installColor := "#16a34a"
if data.ComparedToPrev.InstallsChange < 0 {
installIcon = "📉"
installColor = "#dc2626"
}
failIcon := "✅"
failColor := "#16a34a"
if data.ComparedToPrev.FailRateChange > 0 {
failIcon = "⚠️"
failColor = "#dc2626"
}
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<table width="100%" style="background:#fafafa;border-radius:8px;">
<tr>
<td style="padding:16px;border-right:1px solid #e5e7eb;">
<span style="font-size:12px;color:#64748b;">vs. Previous Week</span><br>
<span style="font-size:16px;color:`)
b.WriteString(installColor)
b.WriteString(`;">`)
b.WriteString(installIcon)
b.WriteString(fmt.Sprintf(" %+d installations (%.1f%%)", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(`</span>
</td>
<td style="padding:16px;">
<span style="font-size:12px;color:#64748b;">Failure Rate Change</span><br>
<span style="font-size:16px;color:`)
b.WriteString(failColor)
b.WriteString(`;">`)
b.WriteString(failIcon)
b.WriteString(fmt.Sprintf(" %+.1f percentage points", data.ComparedToPrev.FailRateChange))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
`)
}
// Top 5 Installed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🏆 Top 5 Installed Scripts</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopApps) > 0 {
for i, app := range data.TopApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#f8fafc"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="background:#e0e7ff;color:#4338ca;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">%d</span>
<span style="margin-left:12px;font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;color:#64748b;">%d installs</td>
</tr>`, bgColor, i+1, app.Name, app.Total))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#64748b;">No data available</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Top 5 Failed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;"> Top 5 Scripts with Highest Failure Rates</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#fef2f2"
}
rateColor := "#dc2626"
if app.FailureRate < 20 {
rateColor = "#ea580c"
}
if app.FailureRate < 10 {
rateColor = "#ca8a04"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:center;color:#64748b;">%d / %d failed</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;">
<span style="background:%s;color:#ffffff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:600;">%.1f%%</span>
</td>
</tr>`, bgColor, app.Name, app.Failed, app.Total, rateColor, app.FailureRate))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#16a34a;">🎉 No failures this week!</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Type Distribution
if len(data.TypeDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">📦 Distribution by Type</h2>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
`)
for t, count := range data.TypeDistribution {
percent := float64(count) / float64(data.TotalInstalls) * 100
b.WriteString(fmt.Sprintf(`<td style="padding:8px;">
<div style="background:#f1f5f9;border-radius:8px;padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#475569;">%d</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">%s (%.1f%%)</div>
</div>
</td>`, count, strings.ToUpper(t), percent))
}
b.WriteString(`</tr>
</table>
</td>
</tr>
`)
}
// OS Distribution
if len(data.OsDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🐧 Top Operating Systems</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
// Sort OS by count
type osEntry struct {
name string
count int
}
var osList []osEntry
for name, count := range data.OsDistribution {
osList = append(osList, osEntry{name, count})
}
for i := 0; i < len(osList); i++ {
for j := i + 1; j < len(osList); j++ {
if osList[j].count > osList[i].count {
osList[i], osList[j] = osList[j], osList[i]
}
}
}
for i, os := range osList {
if i >= 5 {
break
}
percent := float64(os.count) / float64(data.TotalInstalls) * 100
barWidth := int(percent * 2) // Scale for visual
if barWidth > 100 {
barWidth = 100
}
b.WriteString(fmt.Sprintf(`<tr>
<td style="padding:8px 16px;width:100px;">%s</td>
<td style="padding:8px 16px;">
<div style="background:#e2e8f0;border-radius:4px;height:20px;width:100%%;">
<div style="background:linear-gradient(90deg,#667eea,#764ba2);border-radius:4px;height:20px;width:%d%%;"></div>
</div>
</td>
<td style="padding:8px 16px;text-align:right;width:80px;color:#64748b;">%d (%.1f%%)</td>
</tr>`, os.name, barWidth, os.count, percent))
}
b.WriteString(`</table>
</td>
</tr>
`)
}
// Footer
b.WriteString(`<tr>
<td style="padding:24px 40px;background:#f8fafc;border-radius:0 0 12px 12px;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#64748b;text-align:center;">
Generated `)
b.WriteString(time.Now().Format("Jan 02, 2006 at 15:04 MST"))
b.WriteString(`<br>
<a href="https://github.com/community-scripts/ProxmoxVE" style="color:#667eea;text-decoration:none;">ProxmoxVE Helper Scripts</a>
This is an automated report from the telemetry service.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`)
return b.String()
}
// generateWeeklyReportEmail creates the plain text email body (kept for compatibility)
func (a *Alerter) generateWeeklyReportEmail(data *WeeklyReportData) string {
var b strings.Builder
b.WriteString("ProxmoxVE Helper Scripts - Weekly Telemetry Report\n")
b.WriteString("==================================================\n\n")
b.WriteString(fmt.Sprintf("Calendar Week: %d, %d\n", data.CalendarWeek, data.Year))
b.WriteString(fmt.Sprintf("Period: %s - %s\n\n",
data.StartDate.Format("Jan 02, 2006"),
data.EndDate.Format("Jan 02, 2006")))
b.WriteString("OVERVIEW\n")
b.WriteString("--------\n")
b.WriteString(fmt.Sprintf("Total Installations: %d\n", data.TotalInstalls))
b.WriteString(fmt.Sprintf("Successful: %d\n", data.SuccessCount))
b.WriteString(fmt.Sprintf("Failed: %d\n", data.FailedCount))
b.WriteString(fmt.Sprintf("Success Rate: %.1f%%\n\n", data.SuccessRate))
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
b.WriteString("vs. Previous Week:\n")
b.WriteString(fmt.Sprintf(" Installations: %+d (%.1f%%)\n", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(fmt.Sprintf(" Failure Rate: %+.1f pp\n\n", data.ComparedToPrev.FailRateChange))
}
b.WriteString("TOP 5 INSTALLED SCRIPTS\n")
b.WriteString("-----------------------\n")
for i, app := range data.TopApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-25s %5d installs\n", i+1, app.Name, app.Total))
}
b.WriteString("\n")
b.WriteString("TOP 5 FAILED SCRIPTS\n")
b.WriteString("--------------------\n")
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-20s %3d/%3d failed (%.1f%%)\n",
i+1, app.Name, app.Failed, app.Total, app.FailureRate))
}
} else {
b.WriteString("No failures this week!\n")
}
b.WriteString("\n")
b.WriteString("---\n")
b.WriteString(fmt.Sprintf("Generated: %s\n", time.Now().Format("Jan 02, 2006 15:04 MST")))
b.WriteString("This is an automated report from the telemetry service.\n")
return b.String()
}
// TestWeeklyReport sends a test weekly report email
func (a *Alerter) TestWeeklyReport() error {
return a.SendWeeklyReport()
}

173
misc/data/cleanup.go Normal file
View File

@ -0,0 +1,173 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"time"
)
// CleanupConfig holds configuration for the cleanup job
type CleanupConfig struct {
Enabled bool
CheckInterval time.Duration // How often to run cleanup
StuckAfterHours int // Consider "installing" as stuck after X hours
}
// Cleaner handles cleanup of stuck installations
type Cleaner struct {
cfg CleanupConfig
pb *PBClient
}
// NewCleaner creates a new cleaner instance
func NewCleaner(cfg CleanupConfig, pb *PBClient) *Cleaner {
return &Cleaner{
cfg: cfg,
pb: pb,
}
}
// Start begins the cleanup loop
func (c *Cleaner) Start() {
if !c.cfg.Enabled {
log.Println("INFO: cleanup job disabled")
return
}
go c.cleanupLoop()
log.Printf("INFO: cleanup job started (interval: %v, stuck after: %d hours)", c.cfg.CheckInterval, c.cfg.StuckAfterHours)
}
func (c *Cleaner) cleanupLoop() {
// Run immediately on start
c.runCleanup()
ticker := time.NewTicker(c.cfg.CheckInterval)
defer ticker.Stop()
for range ticker.C {
c.runCleanup()
}
}
// runCleanup finds and updates stuck installations
func (c *Cleaner) runCleanup() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Find stuck records
stuckRecords, err := c.findStuckInstallations(ctx)
if err != nil {
log.Printf("WARN: cleanup - failed to find stuck installations: %v", err)
return
}
if len(stuckRecords) == 0 {
log.Printf("INFO: cleanup - no stuck installations found")
return
}
log.Printf("INFO: cleanup - found %d stuck installations", len(stuckRecords))
// Update each record
updated := 0
for _, record := range stuckRecords {
if err := c.markAsUnknown(ctx, record.ID); err != nil {
log.Printf("WARN: cleanup - failed to update record %s: %v", record.ID, err)
continue
}
updated++
}
log.Printf("INFO: cleanup - updated %d stuck installations to 'unknown'", updated)
}
// StuckRecord represents a minimal record for cleanup
type StuckRecord struct {
ID string `json:"id"`
NSAPP string `json:"nsapp"`
Created string `json:"created"`
}
// findStuckInstallations finds records that are stuck in "installing" status
func (c *Cleaner) findStuckInstallations(ctx context.Context) ([]StuckRecord, error) {
if err := c.pb.ensureAuth(ctx); err != nil {
return nil, err
}
// Calculate cutoff time
cutoff := time.Now().Add(-time.Duration(c.cfg.StuckAfterHours) * time.Hour)
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
// Build filter: status='installing' AND created < cutoff
filter := url.QueryEscape(fmt.Sprintf("status='installing' && created<'%s'", cutoffStr))
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=100",
c.pb.baseURL, c.pb.devColl, filter),
nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.pb.token)
resp, err := c.pb.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Items []StuckRecord `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Items, nil
}
// markAsUnknown updates a record's status to "unknown"
func (c *Cleaner) markAsUnknown(ctx context.Context, recordID string) error {
update := TelemetryStatusUpdate{
Status: "unknown",
Error: "Installation timed out - no completion status received",
}
return c.pb.UpdateTelemetryStatus(ctx, recordID, update)
}
// RunNow triggers an immediate cleanup run (for testing/manual trigger)
func (c *Cleaner) RunNow() (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
stuckRecords, err := c.findStuckInstallations(ctx)
if err != nil {
return 0, fmt.Errorf("failed to find stuck installations: %w", err)
}
updated := 0
for _, record := range stuckRecords {
if err := c.markAsUnknown(ctx, record.ID); err != nil {
log.Printf("WARN: cleanup - failed to update record %s: %v", record.ID, err)
continue
}
updated++
}
return updated, nil
}
// GetStuckCount returns the current number of stuck installations
func (c *Cleaner) GetStuckCount(ctx context.Context) (int, error) {
records, err := c.findStuckInstallations(ctx)
if err != nil {
return 0, err
}
return len(records), nil
}

View File

@ -111,12 +111,17 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*Dashboard
data := &DashboardData{}
// Calculate date filter
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02 00:00:00")
filter := url.QueryEscape(fmt.Sprintf("created >= '%s'", since))
// Calculate date filter (days=0 means all entries)
var filter string
if days > 0 {
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02 00:00:00")
filter = url.QueryEscape(fmt.Sprintf("created >= '%s'", since))
} else {
filter = "" // No filter = all entries
}
// Fetch all records for the period
records, err := p.fetchRecords(ctx, filter, 500)
records, err := p.fetchRecords(ctx, filter)
if err != nil {
return nil, err
}
@ -292,17 +297,22 @@ type TelemetryRecord struct {
Created string `json:"created"`
}
func (p *PBClient) fetchRecords(ctx context.Context, filter string, limit int) ([]TelemetryRecord, error) {
func (p *PBClient) fetchRecords(ctx context.Context, filter string) ([]TelemetryRecord, error) {
var allRecords []TelemetryRecord
page := 1
perPage := 100
perPage := 500
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/collections/%s/records?filter=%s&sort=-created&page=%d&perPage=%d",
p.baseURL, p.targetColl, filter, page, perPage),
nil,
)
var url string
if filter != "" {
url = fmt.Sprintf("%s/api/collections/%s/records?filter=%s&sort=-created&page=%d&perPage=%d",
p.baseURL, p.devColl, filter, page, perPage)
} else {
url = fmt.Sprintf("%s/api/collections/%s/records?sort=-created&page=%d&perPage=%d",
p.baseURL, p.devColl, page, perPage)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
@ -325,7 +335,7 @@ func (p *PBClient) fetchRecords(ctx context.Context, filter string, limit int) (
allRecords = append(allRecords, result.Items...)
if len(allRecords) >= limit || len(allRecords) >= result.TotalItems {
if len(allRecords) >= result.TotalItems {
break
}
page++
@ -746,6 +756,36 @@ func DashboardHTML() string {
color: #fff;
}
.quickfilter {
display: flex;
gap: 4px;
background: var(--bg-tertiary);
padding: 4px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.filter-btn {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.filter-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent-blue);
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@ -951,6 +991,98 @@ func DashboardHTML() string {
background: var(--bg-secondary);
}
.admin-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-left: 8px;
}
.admin-btn:hover {
background: var(--accent-blue);
color: #fff;
border-color: var(--accent-blue);
}
.admin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.footer-btn {
background: transparent;
border: none;
color: var(--accent-blue);
cursor: pointer;
font-size: 12px;
padding: 0;
margin-right: 8px;
}
.footer-btn:hover {
text-decoration: underline;
}
.health-modal {
max-width: 400px;
}
.health-status {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.health-status.ok {
background: rgba(63, 185, 80, 0.1);
border: 1px solid var(--accent-green);
}
.health-status.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--accent-red);
}
.health-status .icon {
font-size: 32px;
}
.health-status .details {
flex: 1;
}
.health-status .title {
font-weight: 600;
font-size: 16px;
}
.health-status .subtitle {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.health-info {
font-size: 12px;
color: var(--text-secondary);
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.health-info div {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.pve-version-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
@ -1104,6 +1236,171 @@ func DashboardHTML() string {
color: var(--text-secondary);
font-size: 14px;
}
/* Detail Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9);
transition: transform 0.2s;
}
.modal-overlay.active .modal-content {
transform: scale(1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 10;
}
.modal-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.detail-section-header svg {
opacity: 0.7;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.detail-item {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px 16px;
}
.detail-item .label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.detail-item .value {
font-size: 15px;
font-weight: 500;
word-break: break-word;
}
.detail-item .value.mono {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item .value.status-success { color: var(--accent-green); }
.detail-item .value.status-failed { color: var(--accent-red); }
.detail-item .value.status-installing { color: var(--accent-yellow); }
.error-box {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 8px;
padding: 16px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--accent-red);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
tr.clickable-row {
cursor: pointer;
transition: background 0.15s;
}
tr.clickable-row:hover {
background: rgba(88, 166, 255, 0.1) !important;
}
</style>
</head>
<body>
@ -1116,13 +1413,13 @@ func DashboardHTML() string {
Telemetry Dashboard
</h1>
<div class="controls">
<select id="timeRange">
<option value="7">Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
</select>
<div class="quickfilter">
<button class="filter-btn" data-days="7">7 Days</button>
<button class="filter-btn active" data-days="30">30 Days</button>
<button class="filter-btn" data-days="90">90 Days</button>
<button class="filter-btn" data-days="365">1 Year</button>
<button class="filter-btn" data-days="0">All</button>
</div>
<button class="export-btn" onclick="exportCSV()">Export CSV</button>
<button onclick="refreshData()">Refresh</button>
<button class="theme-toggle" onclick="toggleTheme()">
@ -1277,12 +1574,45 @@ func DashboardHTML() string {
&bull; Telemetry is anonymous and privacy-friendly
</div>
<div>
<a href="/healthz" target="_blank">Health Check</a> &bull;
<a href="/metrics" target="_blank">Metrics</a> &bull;
<button class="footer-btn" onclick="showHealthCheck()">Health Check</button>
<a href="/api/dashboard" target="_blank">API</a>
</div>
</div>
<!-- Health Check Modal -->
<div class="modal-overlay" id="healthModal" onclick="closeHealthModal(event)">
<div class="modal-content health-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>🏥 Health Check</h2>
<button class="modal-close" onclick="closeHealthModal()">&times;</button>
</div>
<div class="modal-body" id="healthModalBody">
<div class="loading">Checking...</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="modal-overlay" id="detailModal" onclick="closeModalOutside(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 id="modalTitle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="9" x2="15" y2="9"/>
<line x1="9" y1="13" x2="15" y2="13"/>
<line x1="9" y1="17" x2="11" y2="17"/>
</svg>
<span>Record Details</span>
</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- Content filled by JavaScript -->
</div>
</div>
</div>
<script>
let charts = {};
let allRecords = [];
@ -1344,7 +1674,8 @@ func DashboardHTML() string {
};
async function fetchData() {
const days = document.getElementById('timeRange').value;
const activeBtn = document.querySelector('.filter-btn.active');
const days = activeBtn ? activeBtn.dataset.days : '30';
try {
const response = await fetch('/api/dashboard?days=' + days);
if (!response.ok) throw new Error('Failed to fetch data');
@ -1546,9 +1877,25 @@ func DashboardHTML() string {
}]
},
options: {
...chartDefaults,
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } }
plugins: { legend: { display: false } },
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#8b949e',
stepSize: 1,
callback: function(value) { return Number.isInteger(value) ? value : ''; }
},
grid: { color: '#30363d' }
},
y: {
ticks: { color: '#8b949e' },
grid: { color: '#30363d' }
}
}
}
});
@ -1652,20 +1999,25 @@ func DashboardHTML() string {
}
}
// Store current records for detail view
let currentRecords = [];
function renderTableRows(records) {
const tbody = document.getElementById('recordsTable');
currentRecords = records; // Store for detail modal
if (records.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="loading">No records found</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="loading">No records found</td></tr>';
return;
}
tbody.innerHTML = records.map(r => {
tbody.innerHTML = records.map((r, index) => {
const statusClass = r.status || 'unknown';
const resources = r.core_count || r.ram_size || r.disk_size
? (r.core_count || '?') + 'C / ' + (r.ram_size ? Math.round(r.ram_size/1024) + 'G' : '?') + ' / ' + (r.disk_size || '?') + 'GB'
: '-';
const created = r.created ? formatTimestamp(r.created) : '-';
return '<tr>' +
return '<tr class="clickable-row" onclick="showRecordDetail(' + index + ')">' +
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></td>' +
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(r.status || '-') + '</span></td>' +
'<td>' + escapeHtml(r.os_type || '-') + ' ' + escapeHtml(r.os_version || '') + '</td>' +
@ -1680,6 +2032,171 @@ func DashboardHTML() string {
}).join('');
}
function showRecordDetail(index) {
const record = currentRecords[index];
if (!record) return;
const modal = document.getElementById('detailModal');
const modalTitle = document.getElementById('modalTitle').querySelector('span');
const modalBody = document.getElementById('modalBody');
modalTitle.textContent = record.nsapp || 'Record Details';
// Build detail content with sections
let html = '';
// General Information Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> General Information</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('App Name', record.nsapp);
html += buildDetailItem('Status', record.status, 'status-' + (record.status || 'unknown'));
html += buildDetailItem('Type', formatType(record.type));
html += buildDetailItem('Method', record.method || 'default');
html += buildDetailItem('Random ID', record.random_id, 'mono');
html += '</div></div>';
// System Resources Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg> System Resources</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('CPU Cores', record.core_count ? record.core_count + ' Cores' : null);
html += buildDetailItem('RAM', record.ram_size ? formatBytes(record.ram_size * 1024 * 1024) : null);
html += buildDetailItem('Disk Size', record.disk_size ? record.disk_size + ' GB' : null);
html += buildDetailItem('CT Type', record.ct_type !== undefined ? (record.ct_type === 1 ? 'Unprivileged' : 'Privileged') : null);
html += '</div></div>';
// Operating System Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> Operating System</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('OS Type', record.os_type);
html += buildDetailItem('OS Version', record.os_version);
html += buildDetailItem('PVE Version', record.pve_version);
html += '</div></div>';
// Hardware Section (CPU & GPU)
const hasHardwareInfo = record.cpu_vendor || record.cpu_model || record.gpu_vendor || record.gpu_model || record.ram_speed;
if (hasHardwareInfo) {
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Hardware</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('CPU Vendor', record.cpu_vendor);
html += buildDetailItem('CPU Model', record.cpu_model);
html += buildDetailItem('RAM Speed', record.ram_speed);
html += buildDetailItem('GPU Vendor', record.gpu_vendor);
html += buildDetailItem('GPU Model', record.gpu_model);
html += buildDetailItem('GPU Passthrough', formatPassthrough(record.gpu_passthrough));
html += '</div></div>';
}
// Installation Details Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Installation</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('Exit Code', record.exit_code !== undefined ? record.exit_code : null, record.exit_code === 0 ? 'status-success' : (record.exit_code ? 'status-failed' : ''));
html += buildDetailItem('Duration', record.install_duration ? formatDuration(record.install_duration) : null);
html += buildDetailItem('Error Category', record.error_category);
html += '</div></div>';
// Error Section (if present)
if (record.error) {
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Error Details</div>';
html += '<div class="error-box">' + escapeHtml(record.error) + '</div>';
html += '</div>';
}
// Timestamps Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Timestamps</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('Created', formatFullTimestamp(record.created));
html += buildDetailItem('Updated', formatFullTimestamp(record.updated));
html += '</div></div>';
modalBody.innerHTML = html;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
function buildDetailItem(label, value, extraClass) {
if (value === null || value === undefined || value === '') {
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="value" style="color: var(--text-secondary);"></div></div>';
}
const valueClass = extraClass ? 'value ' + extraClass : 'value';
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="' + valueClass + '">' + escapeHtml(String(value)) + '</div></div>';
}
function formatType(type) {
if (!type) return null;
const types = {
'lxc': 'LXC Container',
'vm': 'Virtual Machine',
'addon': 'Add-on',
'pve': 'Proxmox VE',
'tool': 'Tool'
};
return types[type.toLowerCase()] || type;
}
function formatPassthrough(pt) {
if (!pt) return null;
const modes = {
'igpu': 'Integrated GPU',
'dgpu': 'Dedicated GPU',
'vgpu': 'Virtual GPU',
'none': 'None',
'unknown': 'Unknown'
};
return modes[pt.toLowerCase()] || pt;
}
function formatBytes(bytes) {
if (!bytes) return null;
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return gb.toFixed(1) + ' GB';
const mb = bytes / (1024 * 1024);
return mb.toFixed(0) + ' MB';
}
function formatDuration(seconds) {
if (!seconds) return null;
if (seconds < 60) return seconds + 's';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins < 60) return mins + 'm ' + secs + 's';
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
return hours + 'h ' + remainMins + 'm';
}
function formatFullTimestamp(ts) {
if (!ts) return null;
const d = new Date(ts);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
}
function closeModal() {
const modal = document.getElementById('detailModal');
modal.classList.remove('active');
document.body.style.overflow = '';
}
function closeModalOutside(event) {
if (event.target === document.getElementById('detailModal')) {
closeModal();
}
}
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
closeHealthModal();
}
});
function filterTable() {
currentPage = 1;
fetchPaginatedRecords();
@ -1717,6 +2234,52 @@ func DashboardHTML() string {
URL.revokeObjectURL(url);
}
async function showHealthCheck() {
const modal = document.getElementById('healthModal');
const body = document.getElementById('healthModalBody');
body.innerHTML = '<div class="loading">Checking...</div>';
modal.classList.add('active');
document.body.style.overflow = 'hidden';
try {
const resp = await fetch('/healthz');
const data = await resp.json();
const isOk = data.status === 'ok';
const statusClass = isOk ? 'ok' : 'error';
const icon = isOk ? '✅' : '❌';
const title = isOk ? 'All Systems Operational' : 'Service Degraded';
let html = '<div class="health-status ' + statusClass + '">';
html += '<span class="icon">' + icon + '</span>';
html += '<div class="details">';
html += '<div class="title">' + title + '</div>';
html += '<div class="subtitle">Last checked: ' + new Date().toLocaleTimeString() + '</div>';
html += '</div></div>';
html += '<div class="health-info">';
html += '<div><span>Status</span><span>' + data.status + '</span></div>';
html += '<div><span>Server Time</span><span>' + new Date(data.time).toLocaleString() + '</span></div>';
if (data.pocketbase) {
html += '<div><span>PocketBase</span><span>' + (data.pocketbase === 'connected' ? '🟢 Connected' : '🔴 ' + data.pocketbase) + '</span></div>';
}
if (data.version) {
html += '<div><span>Version</span><span>' + data.version + '</span></div>';
}
html += '</div>';
body.innerHTML = html;
} catch (e) {
body.innerHTML = '<div class="health-status error"><span class="icon"></span><div class="details"><div class="title">Connection Failed</div><div class="subtitle">' + e.message + '</div></div></div>';
}
}
function closeHealthModal(event) {
if (event && event.target !== document.getElementById('healthModal')) return;
document.getElementById('healthModal').classList.remove('active');
document.body.style.overflow = '';
}
async function refreshData() {
try {
const data = await fetchData();
@ -1732,8 +2295,14 @@ func DashboardHTML() string {
refreshData();
initSortableHeaders();
// Refresh on time range change
document.getElementById('timeRange').addEventListener('change', refreshData);
// Quickfilter button clicks
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
refreshData();
});
});
// Auto-refresh every 60 seconds
setInterval(refreshData, 60000);

View File

@ -12,44 +12,44 @@ export POCKETBASE_COLLECTION="${POCKETBASE_COLLECTION:-$PB_TARGET_COLLECTION}"
# Run migration if enabled
if [ "$RUN_MIGRATION" = "true" ]; then
echo ""
echo "🔄 Migration mode enabled"
echo " Source: $MIGRATION_SOURCE_URL"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Wait for PocketBase to be ready
echo "⏳ Waiting for PocketBase to be ready..."
RETRIES=30
until wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; do
RETRIES=$((RETRIES - 1))
if [ $RETRIES -le 0 ]; then
echo "❌ PocketBase not reachable after 30 attempts"
if [ "$MIGRATION_REQUIRED" = "true" ]; then
exit 1
fi
echo "⚠️ Continuing without migration..."
break
fi
echo " Waiting... ($RETRIES attempts left)"
sleep 2
done
if wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; then
echo "✅ PocketBase is ready"
echo ""
echo "🚀 Starting migration..."
/app/migrate || {
if [ "$MIGRATION_REQUIRED" = "true" ]; then
echo "❌ Migration failed!"
exit 1
fi
echo "⚠️ Migration failed, but continuing..."
}
echo ""
echo ""
echo "🔄 Migration mode enabled"
echo " Source: $MIGRATION_SOURCE_URL"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Wait for PocketBase to be ready
echo "⏳ Waiting for PocketBase to be ready..."
RETRIES=30
until wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; do
RETRIES=$((RETRIES - 1))
if [ $RETRIES -le 0 ]; then
echo "❌ PocketBase not reachable after 30 attempts"
if [ "$MIGRATION_REQUIRED" = "true" ]; then
exit 1
fi
echo "⚠️ Continuing without migration..."
break
fi
echo " Waiting... ($RETRIES attempts left)"
sleep 2
done
if wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; then
echo "✅ PocketBase is ready"
echo ""
echo "🚀 Starting migration..."
/app/migrate || {
if [ "$MIGRATION_REQUIRED" = "true" ]; then
echo "❌ Migration failed!"
exit 1
fi
echo "⚠️ Migration failed, but continuing..."
}
echo ""
fi
fi
echo "🚀 Starting telemetry service..."
exec /app/telemetry-ingest
exec /app/telemetry-service

View File

@ -1,10 +1,10 @@
module telemetry-ingest
module github.com/community-scripts/telemetry-service
go 1.25.5
require github.com/redis/go-redis/v9 v9.7.0
require github.com/redis/go-redis/v9 v9.17.3
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)

View File

@ -2,9 +2,9 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=

View File

@ -0,0 +1,67 @@
#!/bin/bash
# Migration script to import data from the old API to PocketBase
# Usage: ./migrate.sh [POCKETBASE_URL] [COLLECTION_NAME]
#
# Examples:
# ./migrate.sh # Uses defaults
# ./migrate.sh http://localhost:8090 # Custom PB URL
# ./migrate.sh http://localhost:8090 my_telemetry # Custom URL and collection
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values
POCKETBASE_URL="${1:-http://localhost:8090}"
POCKETBASE_COLLECTION="${2:-_dev_telemetry_data}"
echo "============================================="
echo " ProxmoxVED Data Migration Tool"
echo "============================================="
echo ""
echo "This script will migrate telemetry data from:"
echo " Source: https://api.htl-braunau.at/dev/data"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Check if PocketBase is reachable
echo "🔍 Checking PocketBase connection..."
if ! curl -sf "$POCKETBASE_URL/api/health" >/dev/null 2>&1; then
echo "❌ Cannot reach PocketBase at $POCKETBASE_URL"
echo " Make sure PocketBase is running and the URL is correct."
exit 1
fi
echo "✅ PocketBase is reachable"
echo ""
# Check source API
echo "🔍 Checking source API..."
SUMMARY=$(curl -sf "https://api.htl-braunau.at/dev/data/summary" 2>/dev/null || echo "")
if [ -z "$SUMMARY" ]; then
echo "❌ Cannot reach source API"
exit 1
fi
TOTAL=$(echo "$SUMMARY" | grep -o '"total_entries":[0-9]*' | cut -d: -f2)
echo "✅ Source API is reachable ($TOTAL entries available)"
echo ""
# Confirm migration
read -p "⚠️ Do you want to start the migration? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Migration cancelled."
exit 0
fi
echo ""
echo "Starting migration..."
echo ""
# Run the Go migration script
cd "$SCRIPT_DIR"
POCKETBASE_URL="$POCKETBASE_URL" POCKETBASE_COLLECTION="$POCKETBASE_COLLECTION" go run migrate.go
echo ""
echo "Migration complete!"

View File

@ -0,0 +1,492 @@
// +build ignore
// Migration script to import data from the old API to PocketBase
// Run with: go run migrate.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
defaultSourceAPI = "https://api.htl-braunau.at/dev/data"
defaultPBURL = "http://localhost:8090"
batchSize = 100
)
var (
sourceAPI string
summaryAPI string
authToken string // PocketBase auth token
)
// OldDataModel represents the data structure from the old API
type OldDataModel struct {
ID string `json:"id"`
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
}
// PBRecord represents the PocketBase record format
type PBRecord struct {
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
// Temporary field for timestamp migration (PocketBase doesn't allow setting created/updated via API)
// After migration, run SQL: UPDATE installations SET created = old_created, updated = old_created
OldCreated string `json:"old_created,omitempty"`
}
type Summary struct {
TotalEntries int `json:"total_entries"`
}
func main() {
// Setup source URLs
baseURL := os.Getenv("MIGRATION_SOURCE_URL")
if baseURL == "" {
baseURL = defaultSourceAPI
}
sourceAPI = baseURL + "/paginated"
summaryAPI = baseURL + "/summary"
// Support both POCKETBASE_URL and PB_URL (Coolify uses PB_URL)
pbURL := os.Getenv("POCKETBASE_URL")
if pbURL == "" {
pbURL = os.Getenv("PB_URL")
}
if pbURL == "" {
pbURL = defaultPBURL
}
// Support both POCKETBASE_COLLECTION and PB_TARGET_COLLECTION
pbCollection := os.Getenv("POCKETBASE_COLLECTION")
if pbCollection == "" {
pbCollection = os.Getenv("PB_TARGET_COLLECTION")
}
if pbCollection == "" {
pbCollection = "_dev_telemetry_data"
}
// Auth collection
authCollection := os.Getenv("PB_AUTH_COLLECTION")
if authCollection == "" {
authCollection = "_dev_telemetry_service"
}
// Credentials - prefer admin auth for timestamp preservation
pbAdminEmail := os.Getenv("PB_ADMIN_EMAIL")
pbAdminPassword := os.Getenv("PB_ADMIN_PASSWORD")
pbIdentity := os.Getenv("PB_IDENTITY")
pbPassword := os.Getenv("PB_PASSWORD")
fmt.Println("===========================================")
fmt.Println(" Data Migration to PocketBase")
fmt.Println("===========================================")
fmt.Printf("Source API: %s\n", baseURL)
fmt.Printf("PocketBase URL: %s\n", pbURL)
fmt.Printf("Collection: %s\n", pbCollection)
fmt.Println("-------------------------------------------")
// Authenticate with PocketBase - prefer Admin auth for timestamp support
if pbAdminEmail != "" && pbAdminPassword != "" {
fmt.Println("🔐 Authenticating as PocketBase Admin...")
err := authenticateAdmin(pbURL, pbAdminEmail, pbAdminPassword)
if err != nil {
fmt.Printf("❌ Admin authentication failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Admin authentication successful (timestamps will be preserved)")
} else if pbIdentity != "" && pbPassword != "" {
fmt.Println("🔐 Authenticating with PocketBase (collection auth)...")
fmt.Println("⚠️ Note: Timestamps may not be preserved without admin auth")
err := authenticate(pbURL, authCollection, pbIdentity, pbPassword)
if err != nil {
fmt.Printf("❌ Authentication failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Authentication successful")
} else {
fmt.Println("⚠️ No credentials provided, trying without auth...")
}
fmt.Println("-------------------------------------------")
// Get total count
summary, err := getSummary()
if err != nil {
fmt.Printf("❌ Failed to get summary: %v\n", err)
os.Exit(1)
}
fmt.Printf("📊 Total entries to migrate: %d\n", summary.TotalEntries)
fmt.Println("-------------------------------------------")
// Calculate pages
totalPages := (summary.TotalEntries + batchSize - 1) / batchSize
var totalMigrated, totalFailed, totalSkipped int
for page := 1; page <= totalPages; page++ {
fmt.Printf("📦 Fetching page %d/%d (items %d-%d)...\n",
page, totalPages,
(page-1)*batchSize+1,
min(page*batchSize, summary.TotalEntries))
data, err := fetchPage(page, batchSize)
if err != nil {
fmt.Printf(" ❌ Failed to fetch page %d: %v\n", page, err)
totalFailed += batchSize
continue
}
for i, record := range data {
err := importRecord(pbURL, pbCollection, record)
if err != nil {
if isUniqueViolation(err) {
totalSkipped++
continue
}
fmt.Printf(" ❌ Failed to import record %d: %v\n", (page-1)*batchSize+i+1, err)
totalFailed++
continue
}
totalMigrated++
}
fmt.Printf(" ✅ Page %d complete (migrated: %d, skipped: %d, failed: %d)\n",
page, len(data), totalSkipped, totalFailed)
// Small delay to avoid overwhelming the server
time.Sleep(100 * time.Millisecond)
}
fmt.Println("===========================================")
fmt.Println(" Migration Complete")
fmt.Println("===========================================")
fmt.Printf("✅ Successfully migrated: %d\n", totalMigrated)
fmt.Printf("⏭️ Skipped (duplicates): %d\n", totalSkipped)
fmt.Printf("❌ Failed: %d\n", totalFailed)
fmt.Println("===========================================")
}
func getSummary() (*Summary, error) {
resp, err := http.Get(summaryAPI)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var summary Summary
if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
return nil, err
}
return &summary, nil
}
// authenticateAdmin authenticates as PocketBase admin (required for setting timestamps)
func authenticateAdmin(pbURL, email, password string) error {
body := map[string]string{
"identity": email,
"password": password,
}
jsonData, _ := json.Marshal(body)
// Try new PocketBase v0.23+ endpoint first (_superusers collection)
endpoints := []string{
fmt.Sprintf("%s/api/collections/_superusers/auth-with-password", pbURL),
fmt.Sprintf("%s/api/admins/auth-with-password", pbURL), // Legacy endpoint
}
client := &http.Client{Timeout: 10 * time.Second}
var lastErr error
for _, url := range endpoints {
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
lastErr = err
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == 404 {
resp.Body.Close()
continue // Try next endpoint
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
continue
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
lastErr = err
continue
}
resp.Body.Close()
if result.Token == "" {
lastErr = fmt.Errorf("no token in response")
continue
}
authToken = result.Token
return nil
}
return fmt.Errorf("all auth endpoints failed: %v", lastErr)
}
func authenticate(pbURL, authCollection, identity, password string) error {
body := map[string]string{
"identity": identity,
"password": password,
}
jsonData, _ := json.Marshal(body)
url := fmt.Sprintf("%s/api/collections/%s/auth-with-password", pbURL, authCollection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Token == "" {
return fmt.Errorf("no token in response")
}
authToken = result.Token
return nil
}
func fetchPage(page, limit int) ([]OldDataModel, error) {
url := fmt.Sprintf("%s?page=%d&limit=%d", sourceAPI, page, limit)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var data []OldDataModel
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return data, nil
}
func importRecord(pbURL, collection string, old OldDataModel) error {
// Map status: "done" -> "success"
status := old.Status
switch status {
case "done":
status = "success"
case "installing", "failed", "unknown", "success":
// keep as-is
default:
status = "unknown"
}
// ct_type: 1=unprivileged, 2=privileged in old data
// PocketBase might expect 0/1, so normalize to 0 (unprivileged) or 1 (privileged)
ctType := old.CtType
if ctType <= 1 {
ctType = 0 // unprivileged (default)
} else {
ctType = 1 // privileged/VM
}
// Ensure type is set
recordType := old.Type
if recordType == "" {
recordType = "lxc"
}
// Ensure nsapp is set (required field)
nsapp := old.NsApp
if nsapp == "" {
nsapp = "unknown"
}
record := PBRecord{
CtType: ctType,
DiskSize: old.DiskSize,
CoreCount: old.CoreCount,
RamSize: old.RamSize,
OsType: old.OsType,
OsVersion: old.OsVersion,
DisableIP6: old.DisableIP6,
NsApp: nsapp,
Method: old.Method,
PveVersion: old.PveVersion,
Status: status,
RandomID: old.RandomID,
Type: recordType,
Error: old.Error,
OldCreated: convertTimestamp(old.CreatedAt),
}
jsonData, err := json.Marshal(record)
if err != nil {
return err
}
url := fmt.Sprintf("%s/api/collections/%s/records", pbURL, collection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate") ||
contains(errStr, "already exists") ||
contains(errStr, "validation_not_unique")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// convertTimestamp converts various timestamp formats to PocketBase format
// PocketBase expects: "2006-01-02 15:04:05.000Z" or similar
func convertTimestamp(ts string) string {
if ts == "" {
return ""
}
// Try parsing various formats
formats := []string{
time.RFC3339, // "2006-01-02T15:04:05Z07:00"
time.RFC3339Nano, // "2006-01-02T15:04:05.999999999Z07:00"
"2006-01-02T15:04:05.000Z", // ISO with milliseconds
"2006-01-02T15:04:05Z", // ISO without milliseconds
"2006-01-02T15:04:05", // ISO without timezone
"2006-01-02 15:04:05", // SQL format
"2006-01-02 15:04:05.000", // SQL with ms
"2006-01-02 15:04:05.000 UTC", // SQL with UTC
"2006-01-02T15:04:05.000+00:00", // ISO with offset
}
var parsed time.Time
var err error
for _, format := range formats {
parsed, err = time.Parse(format, ts)
if err == nil {
break
}
}
if err != nil {
// If all parsing fails, return empty (PocketBase will set current time)
fmt.Printf(" ⚠️ Could not parse timestamp: %s\n", ts)
return ""
}
// Return in PocketBase format (UTC)
return parsed.UTC().Format("2006-01-02 15:04:05.000Z")
}

View File

@ -27,7 +27,8 @@ type Config struct {
PBAuthCollection string // "_dev_telemetry_service"
PBIdentity string // email
PBPassword string
PBTargetColl string // "_dev_telemetry_data"
PBTargetColl string // "_dev_telemetry_data" (dev default)
PBLiveTargetColl string // "_live_telemetry_data" (production)
// Limits
MaxBodyBytes int64
@ -101,6 +102,9 @@ type TelemetryIn struct {
// Error categorization
ErrorCategory string `json:"error_category,omitempty"` // "network", "storage", "dependency", "permission", "timeout", "unknown"
// Repository source for collection routing
RepoSource string `json:"repo_source,omitempty"` // "community-scripts/ProxmoxVE" or "community-scripts/ProxmoxVED"
}
// TelemetryOut is sent to PocketBase (matches _dev_telemetry_data collection)
@ -146,12 +150,19 @@ type TelemetryStatusUpdate struct {
RAMSpeed string `json:"ram_speed,omitempty"`
}
// Allowed values for 'repo_source' field — controls collection routing
var allowedRepoSource = map[string]bool{
"community-scripts/ProxmoxVE": true,
"community-scripts/ProxmoxVED": true,
}
type PBClient struct {
baseURL string
authCollection string
identity string
password string
targetColl string
devColl string // "_dev_telemetry_data"
liveColl string // "_live_telemetry_data"
mu sync.Mutex
token string
@ -165,13 +176,25 @@ func NewPBClient(cfg Config) *PBClient {
authCollection: cfg.PBAuthCollection,
identity: cfg.PBIdentity,
password: cfg.PBPassword,
targetColl: cfg.PBTargetColl,
devColl: cfg.PBTargetColl,
liveColl: cfg.PBLiveTargetColl,
http: &http.Client{
Timeout: cfg.RequestTimeout,
},
}
}
// resolveCollection maps a repo_source value to the correct PocketBase collection.
// - "community-scripts/ProxmoxVE" → live collection
// - "community-scripts/ProxmoxVED" → dev collection
// - empty / unknown → dev collection (safe default)
func (p *PBClient) resolveCollection(repoSource string) string {
if repoSource == "community-scripts/ProxmoxVE" && p.liveColl != "" {
return p.liveColl
}
return p.devColl
}
func (p *PBClient) ensureAuth(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
@ -223,8 +246,8 @@ func (p *PBClient) ensureAuth(ctx context.Context) error {
return nil
}
// FindRecordByRandomID searches for an existing record by random_id
func (p *PBClient) FindRecordByRandomID(ctx context.Context, randomID string) (string, error) {
// FindRecordByRandomID searches for an existing record by random_id in the given collection
func (p *PBClient) FindRecordByRandomID(ctx context.Context, coll, randomID string) (string, error) {
if err := p.ensureAuth(ctx); err != nil {
return "", err
}
@ -233,7 +256,7 @@ func (p *PBClient) FindRecordByRandomID(ctx context.Context, randomID string) (s
filter := fmt.Sprintf("random_id='%s'", randomID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/collections/%s/records?filter=%s&fields=id&perPage=1",
p.baseURL, p.targetColl, filter),
p.baseURL, coll, filter),
nil,
)
if err != nil {
@ -267,14 +290,14 @@ func (p *PBClient) FindRecordByRandomID(ctx context.Context, randomID string) (s
}
// UpdateTelemetryStatus updates only status, error, and exit_code of an existing record
func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, recordID string, update TelemetryStatusUpdate) error {
func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, coll, recordID string, update TelemetryStatusUpdate) error {
if err := p.ensureAuth(ctx); err != nil {
return err
}
b, _ := json.Marshal(update)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch,
fmt.Sprintf("%s/api/collections/%s/records/%s", p.baseURL, p.targetColl, recordID),
fmt.Sprintf("%s/api/collections/%s/records/%s", p.baseURL, coll, recordID),
bytes.NewReader(b),
)
if err != nil {
@ -295,7 +318,8 @@ func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, recordID string, u
return nil
}
// FetchRecordsPaginated retrieves records with pagination and optional filters
// FetchRecordsPaginated retrieves records with pagination and optional filters.
// Uses devColl by default (dashboard shows dev data); for live data, use separate endpoint if needed.
func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, status, app, osType, sortField string) ([]TelemetryRecord, int, error) {
if err := p.ensureAuth(ctx); err != nil {
return nil, 0, err
@ -337,7 +361,7 @@ func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, s
}
reqURL := fmt.Sprintf("%s/api/collections/%s/records?sort=%s&page=%d&perPage=%d%s",
p.baseURL, p.targetColl, sort, page, limit, filterStr)
p.baseURL, p.devColl, sort, page, limit, filterStr)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
@ -366,17 +390,23 @@ func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, s
return result.Items, result.TotalItems, nil
}
// UpsertTelemetry handles both creation and updates intelligently
// - status="installing": Always creates a new record
// - status!="installing": Updates existing record (found by random_id) with status/error/exit_code only
func (p *PBClient) UpsertTelemetry(ctx context.Context, payload TelemetryOut) error {
// UpsertTelemetry handles both creation and updates intelligently.
// Routes to the correct PocketBase collection based on repoSource:
// - "community-scripts/ProxmoxVE" → _live_telemetry_data
// - "community-scripts/ProxmoxVED" → _dev_telemetry_data
//
// For status="installing": always creates a new record.
// For status!="installing": updates existing record (found by random_id).
func (p *PBClient) UpsertTelemetry(ctx context.Context, payload TelemetryOut, repoSource string) error {
coll := p.resolveCollection(repoSource)
// For "installing" status, always create new record
if payload.Status == "installing" {
return p.CreateTelemetry(ctx, payload)
return p.CreateTelemetry(ctx, coll, payload)
}
// For status updates (success/failed/unknown), find and update existing record
recordID, err := p.FindRecordByRandomID(ctx, payload.RandomID)
recordID, err := p.FindRecordByRandomID(ctx, coll, payload.RandomID)
if err != nil {
// Search failed, log and return error
return fmt.Errorf("cannot find record to update: %w", err)
@ -385,7 +415,7 @@ func (p *PBClient) UpsertTelemetry(ctx context.Context, payload TelemetryOut) er
if recordID == "" {
// Record not found - this shouldn't happen normally
// Create a full record as fallback
return p.CreateTelemetry(ctx, payload)
return p.CreateTelemetry(ctx, coll, payload)
}
// Update only status, error, exit_code, and new metrics fields
@ -402,17 +432,17 @@ func (p *PBClient) UpsertTelemetry(ctx context.Context, payload TelemetryOut) er
CPUModel: payload.CPUModel,
RAMSpeed: payload.RAMSpeed,
}
return p.UpdateTelemetryStatus(ctx, recordID, update)
return p.UpdateTelemetryStatus(ctx, coll, recordID, update)
}
func (p *PBClient) CreateTelemetry(ctx context.Context, payload TelemetryOut) error {
func (p *PBClient) CreateTelemetry(ctx context.Context, coll string, payload TelemetryOut) error {
if err := p.ensureAuth(ctx); err != nil {
return err
}
b, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/api/collections/%s/records", p.baseURL, p.targetColl),
fmt.Sprintf("%s/api/collections/%s/records", p.baseURL, coll),
bytes.NewReader(b),
)
if err != nil {
@ -617,6 +647,9 @@ func validate(in *TelemetryIn) error {
in.RAMSpeed = sanitizeShort(in.RAMSpeed, 16)
in.ErrorCategory = strings.ToLower(sanitizeShort(in.ErrorCategory, 32))
// Sanitize repo_source (routing field)
in.RepoSource = sanitizeShort(in.RepoSource, 64)
// Default empty values to "unknown" for consistency
if in.GPUVendor == "" {
in.GPUVendor = "unknown"
@ -697,6 +730,11 @@ func validate(in *TelemetryIn) error {
return errors.New("invalid install_duration (max 24h)")
}
// Validate repo_source: must be an allowed repository or empty
if in.RepoSource != "" && !allowedRepoSource[in.RepoSource] {
return errors.New("invalid repo_source (must be 'community-scripts/ProxmoxVE' or 'community-scripts/ProxmoxVED')")
}
return nil
}
@ -721,6 +759,7 @@ func main() {
PBIdentity: mustEnv("PB_IDENTITY"),
PBPassword: mustEnv("PB_PASSWORD"),
PBTargetColl: env("PB_TARGET_COLLECTION", "_dev_telemetry_data"),
PBLiveTargetColl: env("PB_LIVE_TARGET_COLLECTION", "_live_telemetry_data"),
MaxBodyBytes: envInt64("MAX_BODY_BYTES", 1024),
RateLimitRPM: envInt("RATE_LIMIT_RPM", 60),
@ -1048,7 +1087,8 @@ func main() {
defer cancel()
// Upsert: Creates new record if random_id doesn't exist, updates if it does
if err := pb.UpsertTelemetry(ctx, out); err != nil {
// Routes to correct collection based on repo_source
if err := pb.UpsertTelemetry(ctx, out, in.RepoSource); err != nil {
// GDPR: don't log raw payload, don't log IPs; log only generic error
log.Printf("pocketbase write failed: %v", err)
http.Error(w, "upstream error", http.StatusBadGateway)