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 `
| App | Status | OS | Type | Method | Resources | Exit Code | Error |
|---|---|---|---|---|---|---|---|
| Loading... | |||||||