package main import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" ) // DashboardData holds aggregated statistics for the dashboard type DashboardData struct { TotalInstalls int `json:"total_installs"` SuccessCount int `json:"success_count"` FailedCount int `json:"failed_count"` InstallingCount int `json:"installing_count"` SuccessRate float64 `json:"success_rate"` TopApps []AppCount `json:"top_apps"` OsDistribution []OsCount `json:"os_distribution"` MethodStats []MethodCount `json:"method_stats"` PveVersions []PveCount `json:"pve_versions"` TypeStats []TypeCount `json:"type_stats"` ErrorAnalysis []ErrorGroup `json:"error_analysis"` FailedApps []AppFailure `json:"failed_apps"` RecentRecords []TelemetryRecord `json:"recent_records"` DailyStats []DailyStat `json:"daily_stats"` } type AppCount struct { App string `json:"app"` Count int `json:"count"` } type OsCount struct { Os string `json:"os"` Count int `json:"count"` } type MethodCount struct { Method string `json:"method"` Count int `json:"count"` } type PveCount struct { Version string `json:"version"` Count int `json:"count"` } type TypeCount struct { Type string `json:"type"` Count int `json:"count"` } type ErrorGroup struct { Pattern string `json:"pattern"` Count int `json:"count"` Apps string `json:"apps"` // Comma-separated list of affected apps } type AppFailure struct { App string `json:"app"` TotalCount int `json:"total_count"` FailedCount int `json:"failed_count"` FailureRate float64 `json:"failure_rate"` } type DailyStat struct { Date string `json:"date"` Success int `json:"success"` Failed int `json:"failed"` } // FetchDashboardData retrieves aggregated data from PocketBase func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*DashboardData, error) { if err := p.ensureAuth(ctx); err != nil { return nil, err } 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)) // Fetch all records for the period records, err := p.fetchRecords(ctx, filter, 500) if err != nil { return nil, err } // Aggregate statistics appCounts := make(map[string]int) appFailures := make(map[string]int) osCounts := make(map[string]int) methodCounts := make(map[string]int) pveCounts := make(map[string]int) typeCounts := make(map[string]int) errorPatterns := make(map[string]map[string]bool) // pattern -> set of apps dailySuccess := make(map[string]int) dailyFailed := make(map[string]int) for _, r := range records { data.TotalInstalls++ switch r.Status { case "success": data.SuccessCount++ case "failed": data.FailedCount++ // Track failed apps if r.NSAPP != "" { appFailures[r.NSAPP]++ } // Group errors by pattern if r.Error != "" { pattern := normalizeError(r.Error) if errorPatterns[pattern] == nil { errorPatterns[pattern] = make(map[string]bool) } if r.NSAPP != "" { errorPatterns[pattern][r.NSAPP] = true } } case "installing": data.InstallingCount++ } // Count apps if r.NSAPP != "" { appCounts[r.NSAPP]++ } // Count OS if r.OsType != "" { osCounts[r.OsType]++ } // Count methods if r.Method != "" { methodCounts[r.Method]++ } // Count PVE versions if r.PveVer != "" { pveCounts[r.PveVer]++ } // Count types (LXC vs VM) if r.Type != "" { typeCounts[r.Type]++ } // Daily stats (use Created field if available) if r.Created != "" { date := r.Created[:10] // "2026-02-09" if r.Status == "success" { dailySuccess[date]++ } else if r.Status == "failed" { dailyFailed[date]++ } } } // Calculate success rate completed := data.SuccessCount + data.FailedCount if completed > 0 { data.SuccessRate = float64(data.SuccessCount) / float64(completed) * 100 } // Convert maps to sorted slices (top 10) data.TopApps = topN(appCounts, 10) data.OsDistribution = topNOs(osCounts, 10) data.MethodStats = topNMethod(methodCounts, 10) data.PveVersions = topNPve(pveCounts, 10) data.TypeStats = topNType(typeCounts, 10) // Error analysis data.ErrorAnalysis = buildErrorAnalysis(errorPatterns, 10) // Failed apps with failure rates data.FailedApps = buildFailedApps(appCounts, appFailures, 10) // Daily stats for chart data.DailyStats = buildDailyStats(dailySuccess, dailyFailed, days) // Recent records (last 20) if len(records) > 20 { data.RecentRecords = records[:20] } else { data.RecentRecords = records } return data, nil } // TelemetryRecord includes Created timestamp type TelemetryRecord struct { TelemetryOut Created string `json:"created"` } func (p *PBClient) fetchRecords(ctx context.Context, filter string, limit int) ([]TelemetryRecord, error) { var allRecords []TelemetryRecord page := 1 perPage := 100 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, ) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.token) resp, err := p.http.Do(req) if err != nil { return nil, err } var result struct { Items []TelemetryRecord `json:"items"` TotalItems int `json:"totalItems"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { resp.Body.Close() return nil, err } resp.Body.Close() allRecords = append(allRecords, result.Items...) if len(allRecords) >= limit || len(allRecords) >= result.TotalItems { break } page++ } return allRecords, nil } func topN(m map[string]int, n int) []AppCount { result := make([]AppCount, 0, len(m)) for k, v := range m { result = append(result, AppCount{App: k, Count: v}) } // Simple bubble sort for small datasets for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func topNOs(m map[string]int, n int) []OsCount { result := make([]OsCount, 0, len(m)) for k, v := range m { result = append(result, OsCount{Os: k, Count: v}) } for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func topNMethod(m map[string]int, n int) []MethodCount { result := make([]MethodCount, 0, len(m)) for k, v := range m { result = append(result, MethodCount{Method: k, Count: v}) } for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func topNPve(m map[string]int, n int) []PveCount { result := make([]PveCount, 0, len(m)) for k, v := range m { result = append(result, PveCount{Version: k, Count: v}) } for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func topNType(m map[string]int, n int) []TypeCount { result := make([]TypeCount, 0, len(m)) for k, v := range m { result = append(result, TypeCount{Type: k, Count: v}) } for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } // normalizeError simplifies error messages into patterns for grouping func normalizeError(err string) string { err = strings.TrimSpace(err) if err == "" { return "unknown" } // Normalize common patterns err = strings.ToLower(err) // Remove specific numbers, IPs, paths that vary // Keep it simple for now - just truncate and normalize if len(err) > 60 { err = err[:60] } // Common error pattern replacements patterns := map[string]string{ "connection refused": "connection refused", "timeout": "timeout", "no space left": "disk full", "permission denied": "permission denied", "not found": "not found", "failed to download": "download failed", "apt": "apt error", "dpkg": "dpkg error", "curl": "network error", "wget": "network error", "docker": "docker error", "systemctl": "systemd error", "service": "service error", } for pattern, label := range patterns { if strings.Contains(err, pattern) { return label } } // If no pattern matches, return first 40 chars if len(err) > 40 { return err[:40] + "..." } return err } func buildErrorAnalysis(patterns map[string]map[string]bool, n int) []ErrorGroup { result := make([]ErrorGroup, 0, len(patterns)) for pattern, apps := range patterns { appList := make([]string, 0, len(apps)) for app := range apps { appList = append(appList, app) } // Limit app list display appsStr := strings.Join(appList, ", ") if len(appsStr) > 50 { appsStr = appsStr[:47] + "..." } result = append(result, ErrorGroup{ Pattern: pattern, Count: len(apps), // Number of unique apps with this error Apps: appsStr, }) } // Sort by count descending for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].Count > result[i].Count { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func buildFailedApps(total, failed map[string]int, n int) []AppFailure { result := make([]AppFailure, 0) for app, failCount := range failed { totalCount := total[app] if totalCount == 0 { continue } rate := float64(failCount) / float64(totalCount) * 100 result = append(result, AppFailure{ App: app, TotalCount: totalCount, FailedCount: failCount, FailureRate: rate, }) } // Sort by failure rate descending for i := 0; i < len(result)-1; i++ { for j := i + 1; j < len(result); j++ { if result[j].FailureRate > result[i].FailureRate { result[i], result[j] = result[j], result[i] } } } if len(result) > n { return result[:n] } return result } func buildDailyStats(success, failed map[string]int, days int) []DailyStat { result := make([]DailyStat, 0, days) for i := days - 1; i >= 0; i-- { date := time.Now().AddDate(0, 0, -i).Format("2006-01-02") result = append(result, DailyStat{ Date: date, Success: success[date], Failed: failed[date], }) } return result } // DashboardHTML returns the embedded dashboard HTML func DashboardHTML() string { return ` Telemetry Dashboard - ProxmoxVE Helper Scripts

Telemetry Dashboard

Total Installations
-
Successful
-
Failed
-
In Progress
-
Success Rate
-
LXC / VM
-

Proxmox VE Versions

Loading...

Installations Over Time

Status Distribution

Top Applications

OS Distribution

Installation Method

Error Analysis

Loading...

Apps with Highest Failure Rates

Loading...

Recent Installations

App Status OS Type Method Resources Exit Code Error
Loading...
` }