diff --git a/alerts.go b/alerts.go index 9e04cf2..10eb92c 100644 --- a/alerts.go +++ b/alerts.go @@ -438,9 +438,10 @@ func (a *Alerter) fetchWeeklyReportData(ctx context.Context) (*WeeklyReportData, TypeDistribution: make(map[string]int), } - // Calculate success rate - if report.TotalInstalls > 0 { - report.SuccessRate = float64(report.SuccessCount) / float64(report.TotalInstalls) * 100 + // Calculate success rate (only success vs failed, excluding aborted) + completed := report.SuccessCount + report.FailedCount + if completed > 0 { + report.SuccessRate = float64(report.SuccessCount) / float64(completed) * 100 } // Top 5 installed apps diff --git a/dashboard.go b/dashboard.go index 653698c..4ccd14f 100644 --- a/dashboard.go +++ b/dashboard.go @@ -29,7 +29,6 @@ type DashboardData struct { FailedApps []AppFailure `json:"failed_apps"` RecentRecords []TelemetryRecord `json:"recent_records"` DailyStats []DailyStat `json:"daily_stats"` - HourlyStats []HourlyStat `json:"hourly_stats,omitempty"` // Extended metrics GPUStats []GPUCount `json:"gpu_stats"` @@ -67,31 +66,18 @@ type TypeCount struct { } type ErrorGroup struct { - Pattern string `json:"pattern"` - Count int `json:"count"` // Total error occurrences - UniqueApps int `json:"unique_apps"` // Number of unique apps affected - Apps string `json:"apps"` // Comma-separated list of affected apps - AppBreakdown []ErrorAppDetail `json:"app_breakdown"` // Per-app counts -} - -type ErrorAppDetail struct { - App string `json:"app"` - Count int `json:"count"` + Pattern string `json:"pattern"` + Count int `json:"count"` // Total error occurrences + UniqueApps int `json:"unique_apps"` // Number of unique apps affected + Apps string `json:"apps"` // Comma-separated list of affected apps } type AppFailure struct { - App string `json:"app"` - Type string `json:"type"` - TotalCount int `json:"total_count"` - FailedCount int `json:"failed_count"` - FailureRate float64 `json:"failure_rate"` - TopErrors []AppErrorDetail `json:"top_errors,omitempty"` -} - -type AppErrorDetail struct { - Pattern string `json:"pattern"` - Count int `json:"count"` - Percent float64 `json:"percent"` + App string `json:"app"` + Type string `json:"type"` + TotalCount int `json:"total_count"` + FailedCount int `json:"failed_count"` + FailureRate float64 `json:"failure_rate"` } type DailyStat struct { @@ -100,12 +86,6 @@ type DailyStat struct { Failed int `json:"failed"` } -type HourlyStat struct { - Hour string `json:"hour"` - Success int `json:"success"` - Failed int `json:"failed"` -} - // Extended metric types type GPUCount struct { Vendor string `json:"vendor"` @@ -180,13 +160,10 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource methodCounts := make(map[string]int) pveCounts := make(map[string]int) typeCounts := make(map[string]int) - errorApps := make(map[string]map[string]int) // pattern -> app -> count + errorApps := make(map[string]map[string]bool) // pattern -> set of apps errorCounts := make(map[string]int) // pattern -> total occurrences - appErrors := make(map[string]map[string]int) // "app|type" -> pattern -> count dailySuccess := make(map[string]int) dailyFailed := make(map[string]int) - hourlySuccess := make(map[string]int) - hourlyFailed := make(map[string]int) // Failure tracking per app+type appTypeCounts := make(map[string]int) @@ -221,20 +198,10 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource pattern := normalizeError(r.Error) errorCounts[pattern]++ if errorApps[pattern] == nil { - errorApps[pattern] = make(map[string]int) + errorApps[pattern] = make(map[string]bool) } if r.NSAPP != "" { - errorApps[pattern][r.NSAPP]++ - // Track per-app errors for failed apps drill-down - typeLabel := r.Type - if typeLabel == "" { - typeLabel = "unknown" - } - aeKey := r.NSAPP + "|" + typeLabel - if appErrors[aeKey] == nil { - appErrors[aeKey] = make(map[string]int) - } - appErrors[aeKey][pattern]++ + errorApps[pattern][r.NSAPP] = true } } case "aborted": @@ -320,15 +287,6 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource } else if r.Status == "failed" { dailyFailed[date]++ } - // Hourly stats for "Today" view - if days <= 1 && len(r.Created) >= 13 { - hour := r.Created[11:13] // "14" from "2026-02-09 14:33:22" - if r.Status == "success" { - hourlySuccess[hour]++ - } else if r.Status == "failed" { - hourlyFailed[hour]++ - } - } } } @@ -348,7 +306,6 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource // Error analysis data.ErrorAnalysis = buildErrorAnalysis(errorApps, errorCounts, 15) - // Failed apps with failure rates - dynamic threshold based on time period minInstalls := 10 // default switch { @@ -364,16 +321,11 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int, repoSource minInstalls = 100 // 1 year+: need at least 100 installs } // Returns 16 items: 8 LXC + 8 VM balanced, LXC prioritized - data.FailedApps = buildFailedApps(appTypeCounts, appTypeFailures, appErrors, 16, minInstalls) + data.FailedApps = buildFailedApps(appTypeCounts, appTypeFailures, 16, minInstalls) // Daily stats for chart data.DailyStats = buildDailyStats(dailySuccess, dailyFailed, days) - // Hourly stats for "Today" view - if days <= 1 { - data.HourlyStats = buildHourlyStats(hourlySuccess, hourlyFailed) - } - // === Extended metrics === // GPU stats @@ -418,7 +370,7 @@ type fetchRecordsResult struct { func (p *PBClient) fetchRecords(ctx context.Context, filter string) (*fetchRecordsResult, error) { var allRecords []TelemetryRecord page := 1 - perPage := 1000 + perPage := 500 totalItems := 0 for { @@ -570,180 +522,52 @@ func normalizeError(err string) string { return "unknown" } - // If it's already a short explain_exit_code result, return as-is (lowercased) - lower := strings.ToLower(err) - if !strings.Contains(err, "\n") && len(err) <= 120 { - // Single-line error from explain_exit_code — classify by keywords - switch { - case strings.Contains(lower, "sigint") || strings.Contains(lower, "ctrl+c") || strings.Contains(lower, "ctrl-c"): - return "aborted by user (SIGINT)" - case strings.Contains(lower, "sigkill") || strings.Contains(lower, "out of memory") || strings.Contains(lower, "killed"): - return "killed (SIGKILL / out of memory?)" - case strings.Contains(lower, "sigterm") || strings.Contains(lower, "terminated"): - return "terminated (SIGTERM)" - case strings.Contains(lower, "command not found"): - return "command not found" - case strings.Contains(lower, "timeout"): - return "timeout" - case strings.Contains(lower, "permission denied") || strings.Contains(lower, "operation not permitted"): - return "general error / operation not permitted" - case strings.Contains(lower, "unknown error"): - return "unknown error" - default: - if len(err) > 80 { - return lower[:80] + "..." - } - return lower + // 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 } } - // Multi-line error text (from get_error_text / last 20 lines of log) - // Extract the most meaningful error line - lines := strings.Split(err, "\n") - var bestLine string - var bestScore int - - // Error indicator keywords scored by relevance - errorIndicators := []struct { - pattern string - score int - }{ - {"error:", 10}, - {"fatal:", 10}, - {"failed", 8}, - {"exception", 8}, - {"e:", 7}, // APT error lines - {"err!", 7}, - {"unable to", 7}, - {"cannot", 6}, - {"no such file", 6}, - {"not found", 6}, - {"permission denied", 6}, - {"dpkg:", 6}, - {"sub-process", 5}, - {"broken", 5}, - {"segfault", 5}, - {"traceback", 5}, - {"panic:", 5}, - {"killed", 5}, - {"abort", 5}, - {"denied", 4}, - {"refused", 4}, - {"timeout", 4}, - {"could not", 4}, - {"no space", 4}, - {"exit code", 3}, - {"exit status", 3}, + // If no pattern matches, return first 40 chars + if len(err) > 40 { + return err[:40] + "..." } - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || trimmed == "---" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "---") { - continue - } - lowerLine := strings.ToLower(trimmed) - - // Skip noisy / unhelpful lines - if strings.HasPrefix(lowerLine, "reading package") || - strings.HasPrefix(lowerLine, "processing") || - strings.HasPrefix(lowerLine, "setting up") || - strings.HasPrefix(lowerLine, "unpacking") || - strings.HasPrefix(lowerLine, "selecting") || - strings.HasPrefix(lowerLine, "preparing") || - strings.HasPrefix(lowerLine, "hit:") || - strings.HasPrefix(lowerLine, "get:") || - strings.HasPrefix(lowerLine, "fetched") { - continue - } - - score := 0 - for _, ind := range errorIndicators { - if strings.Contains(lowerLine, ind.pattern) { - score += ind.score - } - } - - // Prefer longer lines (more context) - if len(trimmed) > 15 { - score++ - } - - if score > bestScore { - bestScore = score - bestLine = trimmed - } - } - - // If we found a good error line, use it - if bestLine != "" && bestScore >= 3 { - result := strings.ToLower(bestLine) - // Classify into broader categories - switch { - case strings.Contains(result, "dpkg") && (strings.Contains(result, "error") || strings.Contains(result, "failed")): - // Keep specific dpkg error - if len(result) > 100 { - return result[:100] + "..." - } - return result - case strings.Contains(result, "apt") || strings.Contains(result, "e: "): - if len(result) > 100 { - return result[:100] + "..." - } - return result - case strings.Contains(result, "no space left"): - return "disk full" - case strings.Contains(result, "connection refused"): - return "connection refused" - default: - if len(result) > 100 { - return result[:100] + "..." - } - return result - } - } - - // Fallback: take the last non-empty meaningful line - for i := len(lines) - 1; i >= 0; i-- { - trimmed := strings.TrimSpace(lines[i]) - if trimmed != "" && len(trimmed) > 5 { - result := strings.ToLower(trimmed) - if len(result) > 80 { - return result[:80] + "..." - } - return result - } - } - - // Last resort - result := strings.ToLower(strings.TrimSpace(err)) - if len(result) > 80 { - return result[:80] + "..." - } - return result + return err } -func buildErrorAnalysis(apps map[string]map[string]int, counts map[string]int, n int) []ErrorGroup { +func buildErrorAnalysis(apps map[string]map[string]bool, counts map[string]int, n int) []ErrorGroup { result := make([]ErrorGroup, 0, len(apps)) - for pattern, appCounts := range apps { - appList := make([]string, 0, len(appCounts)) - breakdown := make([]ErrorAppDetail, 0, len(appCounts)) - for app, count := range appCounts { + for pattern, appSet := range apps { + appList := make([]string, 0, len(appSet)) + for app := range appSet { appList = append(appList, app) - breakdown = append(breakdown, ErrorAppDetail{App: app, Count: count}) - } - - // Sort breakdown by count descending - for i := 0; i < len(breakdown)-1; i++ { - for j := i + 1; j < len(breakdown); j++ { - if breakdown[j].Count > breakdown[i].Count { - breakdown[i], breakdown[j] = breakdown[j], breakdown[i] - } - } - } - // Keep top 10 apps per pattern - if len(breakdown) > 10 { - breakdown = breakdown[:10] } // Limit app list display @@ -753,11 +577,10 @@ func buildErrorAnalysis(apps map[string]map[string]int, counts map[string]int, n } result = append(result, ErrorGroup{ - Pattern: pattern, - Count: counts[pattern], - UniqueApps: len(appCounts), - Apps: appsStr, - AppBreakdown: breakdown, + Pattern: pattern, + Count: counts[pattern], + UniqueApps: len(appSet), + Apps: appsStr, }) } @@ -776,7 +599,7 @@ func buildErrorAnalysis(apps map[string]map[string]int, counts map[string]int, n return result } -func buildFailedApps(total, failed map[string]int, appErrors map[string]map[string]int, n int, minInstalls int) []AppFailure { +func buildFailedApps(total, failed map[string]int, n int, minInstalls int) []AppFailure { lxcApps := make([]AppFailure, 0) vmApps := make([]AppFailure, 0) @@ -803,27 +626,6 @@ func buildFailedApps(total, failed map[string]int, appErrors map[string]map[stri FailureRate: rate, } - // Add top errors for this app - if errMap, ok := appErrors[key]; ok { - topErrors := make([]AppErrorDetail, 0, len(errMap)) - for pattern, count := range errMap { - pct := float64(count) / float64(failCount) * 100 - topErrors = append(topErrors, AppErrorDetail{Pattern: pattern, Count: count, Percent: pct}) - } - // Sort by count descending - for i := 0; i < len(topErrors)-1; i++ { - for j := i + 1; j < len(topErrors); j++ { - if topErrors[j].Count > topErrors[i].Count { - topErrors[i], topErrors[j] = topErrors[j], topErrors[i] - } - } - } - if len(topErrors) > 8 { - topErrors = topErrors[:8] - } - failure.TopErrors = topErrors - } - // Separate LXC and VM apps (LXC has higher priority) if strings.ToLower(appType) == "lxc" { lxcApps = append(lxcApps, failure) @@ -906,19 +708,6 @@ func buildDailyStats(success, failed map[string]int, days int) []DailyStat { return result } -func buildHourlyStats(success, failed map[string]int) []HourlyStat { - result := make([]HourlyStat, 0, 24) - for h := 0; h < 24; h++ { - hour := fmt.Sprintf("%02d", h) - result = append(result, HourlyStat{ - Hour: hour, - Success: success[hour], - Failed: failed[hour], - }) - } - return result -} - // === Extended metrics helper functions === func buildGPUStats(gpuCounts map[string]int) []GPUCount { @@ -1229,12 +1018,18 @@ func DashboardHTML() string { /* Stat Cards Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 20px; margin-bottom: 32px; } - @media (max-width: 1400px) { + @media (max-width: 1600px) { + .stats-grid { + grid-template-columns: repeat(3, 1fr); + } + } + + @media (max-width: 1200px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } @@ -1974,13 +1769,6 @@ func DashboardHTML() string { background: var(--bg-tertiary); border-radius: 6px; padding: 12px; - cursor: pointer; - transition: transform 0.15s, box-shadow 0.15s; - } - - .failed-app-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .failed-app-card .app-name { @@ -2019,13 +1807,6 @@ func DashboardHTML() string { background: var(--bg-tertiary); border-radius: 8px; margin-bottom: 8px; - cursor: pointer; - transition: transform 0.15s, box-shadow 0.15s; - } - - .error-item:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .error-item:last-child { @@ -2127,99 +1908,6 @@ func DashboardHTML() string { color: var(--text-secondary); font-size: 14px; } - - /* ── Mobile Optimizations ── */ - @media (max-width: 768px) { - .filters-bar { - flex-direction: column; - align-items: stretch; - gap: 12px; - padding: 14px 16px; - } - - .filter-group { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - - .quickfilter { - flex-wrap: wrap; - width: 100%; - } - - .filter-btn, .source-btn { - padding: 6px 10px; - font-size: 12px; - flex: 1 1 auto; - text-align: center; - } - - .filter-divider { - width: 100%; - height: 1px; - margin: 0; - } - - .auto-refresh-toggle { - justify-content: flex-start; - } - - .filters-bar > div[style*="margin-left: auto"] { - margin-left: 0 !important; - width: 100%; - justify-content: space-between; - } - - /* OS / Status chart grid */ - .chart-pair-grid { - grid-template-columns: 1fr !important; - } - - /* Modal: full-screen on mobile */ - .modal-content { - width: 100% !important; - max-width: 100% !important; - max-height: 100vh !important; - height: 100vh; - border-radius: 0 !important; - margin: 0; - } - - .modal-overlay { - align-items: stretch !important; - } - - .modal-header { - padding: 16px !important; - } - - .modal-header h2 { - font-size: 16px !important; - } - - .modal-body { - padding: 16px !important; - } - - .detail-grid { - grid-template-columns: 1fr 1fr !important; - } - - .error-box { - font-size: 11px !important; - max-height: 200px; - overflow-y: auto; - } - - .page-header h1 { - font-size: 22px; - } - - .main-content { - padding: 16px 12px; - } - } /* Auto-Refresh Toggle */ .auto-refresh-toggle { @@ -2490,7 +2178,7 @@ func DashboardHTML() string { - + @@ -2596,7 +2284,7 @@ func DashboardHTML() string {
- Failures & Aborted + Failed
@@ -2606,7 +2294,22 @@ func DashboardHTML() string {
-
-
Loading...
+
installation failures
+
+ +
+
+ Aborted +
+ + + + + +
+
+
-
+
user cancelled (Ctrl+C)
@@ -2665,8 +2368,8 @@ func DashboardHTML() string {
-

Installations Over Time

-

Daily success and failure trends.

+

Installations Over Time

+

Daily success and failure trends.

@@ -2674,7 +2377,7 @@ func DashboardHTML() string {
-
+

OS Distribution

@@ -2932,14 +2635,9 @@ func DashboardHTML() string { document.getElementById('cacheStatus').textContent = ''; try { - // Timeout: 5 minutes for large datasets (1 Year) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 300000); - // Add cache-busting timestamp for filter changes to ensure fresh data const cacheBuster = '&_t=' + Date.now(); - const response = await fetch('/api/dashboard?days=' + days + '&repo=' + repo + cacheBuster, { signal: controller.signal }); - clearTimeout(timeoutId); + const response = await fetch('/api/dashboard?days=' + days + '&repo=' + repo + cacheBuster); if (!response.ok) throw new Error('Failed to fetch data'); // Check cache status from header @@ -2948,9 +2646,6 @@ func DashboardHTML() string { return await response.json(); } catch (error) { - if (error.name === 'AbortError') { - error.message = 'Request timed out - try a shorter time period'; - } document.getElementById('error').style.display = 'flex'; document.getElementById('errorText').textContent = error.message; throw error; @@ -2964,18 +2659,15 @@ func DashboardHTML() string { const displayTotal = data.total_all_time || data.total_installs; document.getElementById('totalInstalls').textContent = displayTotal.toLocaleString(); - const totalFailures = (data.failed_count || 0) + (data.aborted_count || 0); - document.getElementById('failedCount').textContent = totalFailures.toLocaleString(); + // Failed count (separate card) + document.getElementById('failedCount').textContent = (data.failed_count || 0).toLocaleString(); + document.getElementById('failedSubtitle').textContent = data.failed_count > 0 ? 'installation failures' : 'no failures'; - // Show breakdown in subtitle - const failedText = data.failed_count > 0 ? data.failed_count.toLocaleString() + ' errors' : ''; - const abortedText = data.aborted_count > 0 ? data.aborted_count.toLocaleString() + ' aborted (Ctrl+C)' : ''; - const subtitleParts = [failedText, abortedText].filter(x => x); - document.getElementById('failedSubtitle').textContent = subtitleParts.length > 0 ? subtitleParts.join(' • ') : 'No failures recorded'; + // Aborted count (separate card) + document.getElementById('abortedCount').textContent = (data.aborted_count || 0).toLocaleString(); document.getElementById('successRate').textContent = data.success_rate.toFixed(1) + '%'; - const avgText = data.avg_install_duration > 0 ? ' • ⌀ ' + formatDuration(Math.round(data.avg_install_duration)) : ''; - document.getElementById('successSubtitle').textContent = data.success_count.toLocaleString() + ' successful' + avgText; + document.getElementById('successSubtitle').textContent = data.success_count.toLocaleString() + ' successful installations'; document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString(); document.getElementById('error').style.display = 'none'; @@ -3014,56 +2706,15 @@ func DashboardHTML() string { return; } - window._errorAnalysisData = errors; - - container.innerHTML = errors.slice(0, 8).map(function(e, idx) { - return '
' + + container.innerHTML = errors.slice(0, 8).map(e => + '
' + '
' + '
' + escapeHtml(e.pattern) + '
' + '
' + e.unique_apps + ' apps affected: ' + escapeHtml(e.apps) + '
' + '
' + '' + e.count.toLocaleString() + ' occurrences' + - '
'; - }).join(''); - } - - function showErrorBreakdown(idx) { - const err = window._errorAnalysisData[idx]; - if (!err) return; - - const modal = document.getElementById('detailModal'); - const modalBody = document.getElementById('modalBody'); - - let html = '
'; - html += '
Error Pattern Details
'; - html += '
' + escapeHtml(err.pattern) + '
'; - html += '
'; - html += '
Total Occurrences
' + err.count.toLocaleString() + '
'; - html += '
Apps Affected
' + err.unique_apps + '
'; - html += '
'; - - if (err.app_breakdown && err.app_breakdown.length > 0) { - html += '
'; - html += '
Affected Scripts
'; - err.app_breakdown.forEach(function(a) { - const pct = (a.count / err.count * 100).toFixed(1); - html += '
'; - html += '
'; - html += '
' + escapeHtml(a.app) + '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '' + a.count + 'x'; - html += '
' + pct + '%
'; - html += '
'; - }); - html += '
'; - } - - modalBody.innerHTML = html; - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; + '
' + ).join(''); } function updateFailedApps(apps) { @@ -3086,54 +2737,15 @@ func DashboardHTML() string { } // Show all apps (balanced: LXC first, then VMs) - container.innerHTML = apps.map(function(a, idx) { + container.innerHTML = apps.map(a => { const typeClass = (a.type || '').toLowerCase(); const typeBadge = a.type && a.type !== 'unknown' ? '' + a.type.toUpperCase() + ' ' : ''; - return '
' + + return '
' + '
' + typeBadge + escapeHtml(a.app) + '
' + '
' + a.failure_rate.toFixed(1) + '%
' + '
' + a.failed_count + ' / ' + a.total_count + ' failed
' + '
'; }).join(''); - - // Store for modal access - window._failedAppsData = apps; - } - - function showAppErrors(idx) { - const app = window._failedAppsData[idx]; - if (!app) return; - - const modal = document.getElementById('detailModal'); - const modalBody = document.getElementById('modalBody'); - - let html = '
'; - html += '
' + escapeHtml(app.app) + ' — Error Breakdown
'; - html += '
'; - html += '
Total Installs
' + app.total_count.toLocaleString() + '
'; - html += '
Failed
' + app.failed_count.toLocaleString() + '
'; - html += '
Failure Rate
' + app.failure_rate.toFixed(1) + '%
'; - html += '
Type
' + (app.type || 'unknown').toUpperCase() + '
'; - html += '
'; - - if (app.top_errors && app.top_errors.length > 0) { - html += '
'; - html += '
Top Errors
'; - app.top_errors.forEach(function(e) { - html += '
'; - html += '
' + escapeHtml(e.pattern) + '
'; - html += '
' + e.count + ' occurrences
'; - html += '
' + e.percent.toFixed(1) + '%
'; - html += '
'; - }); - html += '
'; - } else { - html += '
No error details available
'; - } - - modalBody.innerHTML = html; - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; } function escapeHtml(str) { @@ -3265,89 +2877,38 @@ func DashboardHTML() string { } function updateCharts(data) { - // Daily / Hourly chart + // Daily chart if (charts.daily) charts.daily.destroy(); - const isToday = data.hourly_stats && data.hourly_stats.length > 0; - document.getElementById('timeChartTitle').textContent = isToday ? 'Installations Today (Hourly)' : 'Installations Over Time'; - document.getElementById('timeChartSubtitle').textContent = isToday ? 'Hourly success and failure breakdown for today.' : 'Daily success and failure trends.'; - if (isToday) { - // Hourly bar chart for "Today" - charts.daily = new Chart(document.getElementById('dailyChart'), { - type: 'bar', - data: { - labels: data.hourly_stats.map(h => h.hour + ':00'), - datasets: [ - { - label: 'Success', - data: data.hourly_stats.map(h => h.success), - backgroundColor: 'rgba(34, 197, 94, 0.7)', - borderColor: '#22c55e', - borderWidth: 1, - borderRadius: 3, - borderSkipped: false - }, - { - label: 'Failed', - data: data.hourly_stats.map(h => h.failed), - backgroundColor: 'rgba(239, 68, 68, 0.7)', - borderColor: '#ef4444', - borderWidth: 1, - borderRadius: 3, - borderSkipped: false - } - ] - }, - options: { - ...chartDefaults, - plugins: { legend: { display: true, position: 'top', labels: { color: '#8b949e', usePointStyle: true } } }, - scales: { - x: { - ticks: { color: '#8b949e', maxRotation: 45, font: { size: 11 } }, - grid: { color: 'rgba(45, 55, 72, 0.5)' }, - stacked: true - }, - y: { - ticks: { color: '#8b949e', precision: 0 }, - grid: { color: 'rgba(45, 55, 72, 0.5)' }, - stacked: true, - beginAtZero: true - } + charts.daily = new Chart(document.getElementById('dailyChart'), { + type: 'line', + data: { + labels: data.daily_stats.map(d => d.date.slice(5)), + datasets: [ + { + label: 'Success', + data: data.daily_stats.map(d => d.success), + borderColor: '#22c55e', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + fill: true, + tension: 0.4, + borderWidth: 2 + }, + { + label: 'Failed', + data: data.daily_stats.map(d => d.failed), + borderColor: '#ef4444', + backgroundColor: 'rgba(239, 68, 68, 0.1)', + fill: true, + tension: 0.4, + borderWidth: 2 } - } - }); - } else { - // Daily line chart for multi-day periods - charts.daily = new Chart(document.getElementById('dailyChart'), { - type: 'line', - data: { - labels: data.daily_stats.map(d => d.date.slice(5)), - datasets: [ - { - label: 'Success', - data: data.daily_stats.map(d => d.success), - borderColor: '#22c55e', - backgroundColor: 'rgba(34, 197, 94, 0.1)', - fill: true, - tension: 0.4, - borderWidth: 2 - }, - { - label: 'Failed', - data: data.daily_stats.map(d => d.failed), - borderColor: '#ef4444', - backgroundColor: 'rgba(239, 68, 68, 0.1)', - fill: true, - tension: 0.4, - borderWidth: 2 - } - ] - }, - options: { - ...chartDefaults, - plugins: { legend: { display: true, position: 'top', labels: { color: '#8b949e', usePointStyle: true } } } - } - }); - } + ] + }, + options: { + ...chartDefaults, + plugins: { legend: { display: true, position: 'top', labels: { color: '#8b949e', usePointStyle: true } } } + } + }); // OS distribution - horizontal bar chart if (charts.os) charts.os.destroy(); diff --git a/service.go b/service.go index 6418db6..972117d 100644 --- a/service.go +++ b/service.go @@ -392,8 +392,7 @@ func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, s defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) - return nil, 0, fmt.Errorf("pocketbase fetch failed: %s: %s (url=%s)", resp.Status, strings.TrimSpace(string(rb)), reqURL) + return nil, 0, fmt.Errorf("pocketbase fetch failed: %s", resp.Status) } var result struct { @@ -608,10 +607,6 @@ var ( "debian": true, "ubuntu": true, "alpine": true, "devuan": true, "fedora": true, "rocky": true, "alma": true, "centos": true, "opensuse": true, "gentoo": true, "openeuler": true, - // VM-specific OS types - "homeassistant": true, "opnsense": true, "openwrt": true, - "arch-linux": true, "mikrotik": true, "pimox-haos": true, - "turnkey-nextcloud": true, "owncloud": true, "umbrel-os": true, } // Allowed values for 'gpu_vendor' field @@ -626,7 +621,7 @@ var ( // Allowed values for 'error_category' field allowedErrorCategory = map[string]bool{ "network": true, "storage": true, "dependency": true, "permission": true, - "timeout": true, "config": true, "resource": true, "aborted": true, "unknown": true, "": true, + "timeout": true, "config": true, "resource": true, "unknown": true, "": true, } ) @@ -791,7 +786,7 @@ func main() { PBPassword: mustEnv("PB_PASSWORD"), PBTargetColl: mustEnv("PB_TARGET_COLLECTION"), - MaxBodyBytes: envInt64("MAX_BODY_BYTES", 32768), + MaxBodyBytes: envInt64("MAX_BODY_BYTES", 1024), RateLimitRPM: envInt("RATE_LIMIT_RPM", 60), RateBurst: envInt("RATE_BURST", 20), RateKeyMode: env("RATE_KEY_MODE", "ip"), // "ip" or "header" @@ -962,7 +957,7 @@ func main() { } // Increase timeout for large datasets (dashboard aggregation takes time) - ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) defer cancel() // Try cache first (stale-while-revalidate) @@ -979,7 +974,7 @@ func main() { if cache.TryStartRefresh(cacheKey) { go func() { defer cache.FinishRefresh(cacheKey) - refreshCtx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + refreshCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() freshData, err := pb.FetchDashboardData(refreshCtx, days, repoSource) if err != nil { @@ -1159,8 +1154,7 @@ func main() { r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes) raw, err := io.ReadAll(r.Body) if err != nil { - log.Printf("telemetry rejected: body read error (maxBytes=%d): %v", cfg.MaxBodyBytes, err) - http.Error(w, "body too large", http.StatusBadRequest) + http.Error(w, "invalid body", http.StatusBadRequest) return } @@ -1169,13 +1163,14 @@ func main() { dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { - log.Printf("telemetry rejected: json decode error: %v (payload %d bytes)", err, len(raw)) - http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest) + http.Error(w, "invalid json", http.StatusBadRequest) return } if err := validate(&in); err != nil { - log.Printf("telemetry rejected: validation error: %v (nsapp=%s status=%s)", err, in.NSAPP, in.Status) - http.Error(w, fmt.Sprintf("validation: %v", err), http.StatusBadRequest) + if cfg.EnableReqLogging { + log.Printf("telemetry rejected: %v", err) + } + http.Error(w, "invalid payload", http.StatusBadRequest) return }