@@ -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 '
'
+ ).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' ? '
' +
+ 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 += '';
- 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 += '';
- 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
}