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"` // Extended metrics GPUStats []GPUCount `json:"gpu_stats"` ErrorCategories []ErrorCatCount `json:"error_categories"` TopTools []ToolCount `json:"top_tools"` TopAddons []AddonCount `json:"top_addons"` AvgInstallDuration float64 `json:"avg_install_duration"` // seconds TotalTools int `json:"total_tools"` TotalAddons int `json:"total_addons"` } 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"` } // Extended metric types type GPUCount struct { Vendor string `json:"vendor"` Passthrough string `json:"passthrough"` Count int `json:"count"` } type ErrorCatCount struct { Category string `json:"category"` Count int `json:"count"` } type ToolCount struct { Tool string `json:"tool"` Count int `json:"count"` } type AddonCount struct { Addon string `json:"addon"` Count int `json:"count"` } // FetchDashboardData retrieves aggregated data from PocketBase // repoSource filters by repo_source field ("ProxmoxVE", "ProxmoxVED", "external", or "" for all) func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource string) (*DashboardData, error) { if err := p.ensureAuth(ctx); err != nil { return nil, err } data := &DashboardData{} // Build filter parts var filterParts []string // Date filter (days=0 means all entries) if days > 0 { since := time.Now().AddDate(0, 0, -days).Format("2006-01-02 00:00:00") filterParts = append(filterParts, fmt.Sprintf("created >= '%s'", since)) } // Repo source filter if repoSource != "" { filterParts = append(filterParts, fmt.Sprintf("repo_source = '%s'", repoSource)) } var filter string if len(filterParts) > 0 { filter = url.QueryEscape(strings.Join(filterParts, " && ")) } // Fetch all records for the period records, err := p.fetchRecords(ctx, filter) 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) // Extended metrics maps gpuCounts := make(map[string]int) // "vendor|passthrough" -> count errorCatCounts := make(map[string]int) // category -> count toolCounts := make(map[string]int) // tool_name -> count addonCounts := make(map[string]int) // addon_name -> count var totalDuration, durationCount 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]++ } // === Extended metrics tracking === // Track tool executions (type="tool", tool name is in nsapp) if r.Type == "tool" && r.NSAPP != "" { toolCounts[r.NSAPP]++ data.TotalTools++ } // Track addon installations if r.Type == "addon" { addonCounts[r.NSAPP]++ data.TotalAddons++ } // Track GPU usage if r.GPUVendor != "" { key := r.GPUVendor if r.GPUPassthrough != "" { key += "|" + r.GPUPassthrough } gpuCounts[key]++ } // Track error categories if r.Status == "failed" && r.ErrorCategory != "" { errorCatCounts[r.ErrorCategory]++ } // Track install duration (for averaging) if r.InstallDuration > 0 { totalDuration += r.InstallDuration durationCount++ } // 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) // === Extended metrics === // GPU stats data.GPUStats = buildGPUStats(gpuCounts) // Error categories data.ErrorCategories = buildErrorCategories(errorCatCounts) // Top tools data.TopTools = buildToolStats(toolCounts, 10) // Top addons data.TopAddons = buildAddonStats(addonCounts, 10) // Average install duration if durationCount > 0 { data.AvgInstallDuration = float64(totalDuration) / float64(durationCount) } // 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) ([]TelemetryRecord, error) { var allRecords []TelemetryRecord page := 1 perPage := 500 for { var url string if filter != "" { url = fmt.Sprintf("%s/api/collections/%s/records?filter=%s&sort=-created&page=%d&perPage=%d", p.baseURL, p.targetColl, filter, page, perPage) } else { url = fmt.Sprintf("%s/api/collections/%s/records?sort=-created&page=%d&perPage=%d", p.baseURL, p.targetColl, page, perPage) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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) >= 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 } // === Extended metrics helper functions === func buildGPUStats(gpuCounts map[string]int) []GPUCount { result := make([]GPUCount, 0, len(gpuCounts)) for key, count := range gpuCounts { parts := strings.Split(key, "|") vendor := parts[0] passthrough := "" if len(parts) > 1 { passthrough = parts[1] } result = append(result, GPUCount{ Vendor: vendor, Passthrough: passthrough, Count: count, }) } // 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] } } } return result } func buildErrorCategories(catCounts map[string]int) []ErrorCatCount { result := make([]ErrorCatCount, 0, len(catCounts)) for cat, count := range catCounts { result = append(result, ErrorCatCount{ Category: cat, Count: count, }) } // 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] } } } return result } func buildToolStats(toolCounts map[string]int, n int) []ToolCount { result := make([]ToolCount, 0, len(toolCounts)) for tool, count := range toolCounts { result = append(result, ToolCount{ Tool: tool, Count: count, }) } // 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 buildAddonStats(addonCounts map[string]int, n int) []AddonCount { result := make([]AddonCount, 0, len(addonCounts)) for addon, count := range addonCounts { result = append(result, AddonCount{ Addon: addon, Count: count, }) } // 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 } // 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 Created ▼
Loading...
` }