feat(telemetry): add caching, alerts, migration & dashboard improvements
- Add Redis/in-memory caching layer (cache.go) - Add SMTP alerting for high failure rates (alerts.go) - Add data migration script from old API (migrate.go) - Add docker-compose.yml for easy deployment - Move dashboard to / with redirect from /dashboard - Add dark/light mode toggle - Add error analysis and failed apps statistics - Add PVE version and LXC/VM type stats - Add /metrics Prometheus endpoint - Add /api/records pagination endpoint - Add CSV export functionality - Enhanced healthcheck with PB connection status New ENV vars: - Cache: ENABLE_CACHE, CACHE_TTL_SECONDS, ENABLE_REDIS, REDIS_URL - Alerts: ALERT_ENABLED, SMTP_*, ALERT_FAILURE_THRESHOLD, etc. - Migration: RUN_MIGRATION, MIGRATION_REQUIRED, MIGRATION_SOURCE_URL
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,10 @@ type DashboardData struct {
|
||||
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"`
|
||||
}
|
||||
@@ -38,6 +43,29 @@ type MethodCount struct {
|
||||
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"`
|
||||
@@ -64,8 +92,12 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*Dashboard
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -77,6 +109,20 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*Dashboard
|
||||
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++
|
||||
}
|
||||
@@ -96,6 +142,16 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*Dashboard
|
||||
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"
|
||||
@@ -117,6 +173,14 @@ func (p *PBClient) FetchDashboardData(ctx context.Context, days int) (*Dashboard
|
||||
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)
|
||||
@@ -234,6 +298,158 @@ func topNMethod(m map[string]int, n int) []MethodCount {
|
||||
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-- {
|
||||
@@ -254,7 +470,9 @@ func DashboardHTML() string {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Telemetry Dashboard - Community Scripts</title>
|
||||
<title>Telemetry Dashboard - ProxmoxVE Helper Scripts</title>
|
||||
<meta name="description" content="Installation telemetry dashboard for ProxmoxVE Helper Scripts">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
@@ -271,6 +489,20 @@ func DashboardHTML() string {
|
||||
--accent-purple: #a371f7;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f6f8fa;
|
||||
--bg-tertiary: #eaeef2;
|
||||
--border-color: #d0d7de;
|
||||
--text-primary: #1f2328;
|
||||
--text-secondary: #656d76;
|
||||
--accent-blue: #0969da;
|
||||
--accent-green: #1a7f37;
|
||||
--accent-red: #cf222e;
|
||||
--accent-yellow: #9a6700;
|
||||
--accent-purple: #8250df;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -493,6 +725,190 @@ func DashboardHTML() string {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pve-version-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pve-version-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pve-versions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pve-badge {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pve-badge .count {
|
||||
background: var(--accent-purple);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.error-analysis-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-analysis-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-item .pattern {
|
||||
font-family: monospace;
|
||||
color: var(--accent-red);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-item .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-item .count-badge {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.failed-apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.failed-app-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.failed-app-card .app-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.failed-app-card .failure-rate {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.failed-app-card .details {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -510,8 +926,13 @@ func DashboardHTML() string {
|
||||
<option value="14">Last 14 days</option>
|
||||
<option value="30" selected>Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="365">Last year</option>
|
||||
</select>
|
||||
<button class="export-btn" onclick="exportCSV()">Export CSV</button>
|
||||
<button onclick="refreshData()">Refresh</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()">
|
||||
<span id="themeIcon">🌙</span>
|
||||
</button>
|
||||
<span class="last-updated" id="lastUpdated"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,6 +960,17 @@ func DashboardHTML() string {
|
||||
<div class="label">Success Rate</div>
|
||||
<div class="value rate" id="successRate">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">LXC / VM</div>
|
||||
<div class="value" id="typeStats" style="font-size: 20px;">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pve-version-card">
|
||||
<h3>Proxmox VE Versions</h3>
|
||||
<div class="pve-versions" id="pveVersions">
|
||||
<span class="loading">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
@@ -577,6 +1009,34 @@ func DashboardHTML() string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-analysis-card">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
Error Analysis
|
||||
</h3>
|
||||
<div class="error-list" id="errorList">
|
||||
<span class="loading">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-analysis-card">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
Apps with Highest Failure Rates
|
||||
</h3>
|
||||
<div class="failed-apps-grid" id="failedAppsGrid">
|
||||
<span class="loading">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3>Recent Installations</h3>
|
||||
<div class="filters">
|
||||
@@ -599,19 +1059,63 @@ func DashboardHTML() string {
|
||||
<th>OS</th>
|
||||
<th>Type</th>
|
||||
<th>Method</th>
|
||||
<th>Resources</th>
|
||||
<th>Exit Code</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recordsTable">
|
||||
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
||||
<tr><td colspan="8" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<button onclick="prevPage()" id="prevBtn" disabled>← Previous</button>
|
||||
<span id="pageInfo">Page 1</span>
|
||||
<button onclick="nextPage()" id="nextBtn">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<a href="https://github.com/community-scripts/ProxmoxVED" target="_blank">ProxmoxVE Helper Scripts</a>
|
||||
• Telemetry is anonymous and privacy-friendly
|
||||
</div>
|
||||
<div>
|
||||
<a href="/healthz" target="_blank">Health Check</a> •
|
||||
<a href="/metrics" target="_blank">Metrics</a> •
|
||||
<a href="/api/dashboard" target="_blank">API</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let charts = {};
|
||||
let allRecords = [];
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentTheme = localStorage.getItem('theme') || 'dark';
|
||||
|
||||
// Apply saved theme on load
|
||||
if (currentTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.getElementById('themeIcon').textContent = '☀️';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
if (currentTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.getElementById('themeIcon').textContent = '☀️';
|
||||
currentTheme = 'light';
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.getElementById('themeIcon').textContent = '🌙';
|
||||
currentTheme = 'dark';
|
||||
}
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
// Redraw charts with new colors
|
||||
if (Object.keys(charts).length > 0) {
|
||||
refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
const chartColors = {
|
||||
blue: 'rgba(88, 166, 255, 0.8)',
|
||||
@@ -663,6 +1167,68 @@ func DashboardHTML() string {
|
||||
document.getElementById('successRate').textContent = data.success_rate.toFixed(1) + '%';
|
||||
document.getElementById('lastUpdated').textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||||
document.getElementById('error').style.display = 'none';
|
||||
|
||||
// Type stats (LXC/VM)
|
||||
if (data.type_stats && data.type_stats.length > 0) {
|
||||
const lxc = data.type_stats.find(t => t.type === 'lxc');
|
||||
const vm = data.type_stats.find(t => t.type === 'vm');
|
||||
document.getElementById('typeStats').textContent =
|
||||
(lxc ? lxc.count.toLocaleString() : '0') + ' / ' + (vm ? vm.count.toLocaleString() : '0');
|
||||
}
|
||||
|
||||
// PVE Versions
|
||||
if (data.pve_versions && data.pve_versions.length > 0) {
|
||||
document.getElementById('pveVersions').innerHTML = data.pve_versions.map(p =>
|
||||
'<span class="pve-badge">PVE ' + (p.version || 'unknown') + ' <span class="count">' + p.count + '</span></span>'
|
||||
).join('');
|
||||
} else {
|
||||
document.getElementById('pveVersions').innerHTML = '<span>No version data</span>';
|
||||
}
|
||||
|
||||
// Error Analysis
|
||||
updateErrorAnalysis(data.error_analysis || []);
|
||||
|
||||
// Failed Apps
|
||||
updateFailedApps(data.failed_apps || []);
|
||||
}
|
||||
|
||||
function updateErrorAnalysis(errors) {
|
||||
const container = document.getElementById('errorList');
|
||||
if (!errors || errors.length === 0) {
|
||||
container.innerHTML = '<span class="loading">No errors recorded</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = errors.slice(0, 8).map(e =>
|
||||
'<div class="error-item">' +
|
||||
'<div>' +
|
||||
'<div class="pattern">' + escapeHtml(e.pattern) + '</div>' +
|
||||
'<div class="meta">Affects: ' + escapeHtml(e.apps) + '</div>' +
|
||||
'</div>' +
|
||||
'<span class="count-badge">' + e.count + ' apps</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function updateFailedApps(apps) {
|
||||
const container = document.getElementById('failedAppsGrid');
|
||||
if (!apps || apps.length === 0) {
|
||||
container.innerHTML = '<span class="loading">No failures recorded</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = apps.slice(0, 8).map(a =>
|
||||
'<div class="failed-app-card">' +
|
||||
'<div class="app-name">' + escapeHtml(a.app) + '</div>' +
|
||||
'<div class="failure-rate">' + a.failure_rate.toFixed(1) + '%</div>' +
|
||||
'<div class="details">' + a.failed_count + ' / ' + a.total_count + ' failed</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function updateCharts(data) {
|
||||
@@ -793,39 +1359,109 @@ func DashboardHTML() string {
|
||||
filterTable();
|
||||
}
|
||||
|
||||
function filterTable() {
|
||||
const appFilter = document.getElementById('filterApp').value.toLowerCase();
|
||||
const statusFilter = document.getElementById('filterStatus').value;
|
||||
const osFilter = document.getElementById('filterOs').value;
|
||||
|
||||
const filtered = allRecords.filter(r => {
|
||||
if (appFilter && !r.nsapp.toLowerCase().includes(appFilter)) return false;
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
if (osFilter && r.os_type !== osFilter) return false;
|
||||
return true;
|
||||
});
|
||||
async function fetchPaginatedRecords() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const app = document.getElementById('filterApp').value;
|
||||
const os = document.getElementById('filterOs').value;
|
||||
|
||||
try {
|
||||
let url = '/api/records?page=' + currentPage + '&limit=50';
|
||||
if (status) url += '&status=' + encodeURIComponent(status);
|
||||
if (app) url += '&app=' + encodeURIComponent(app);
|
||||
if (os) url += '&os=' + encodeURIComponent(os);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch records');
|
||||
const data = await response.json();
|
||||
|
||||
totalPages = data.total_pages || 1;
|
||||
document.getElementById('pageInfo').textContent = 'Page ' + currentPage + ' of ' + totalPages + ' (' + data.total + ' total)';
|
||||
document.getElementById('prevBtn').disabled = currentPage <= 1;
|
||||
document.getElementById('nextBtn').disabled = currentPage >= totalPages;
|
||||
|
||||
renderTableRows(data.records || []);
|
||||
} catch (e) {
|
||||
console.error('Pagination error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableRows(records) {
|
||||
const tbody = document.getElementById('recordsTable');
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">No records found</td></tr>';
|
||||
if (records.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading">No records found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.slice(0, 50).map(r => {
|
||||
tbody.innerHTML = records.map(r => {
|
||||
const statusClass = r.status || 'unknown';
|
||||
const resources = r.core_count || r.ram_size || r.disk_size
|
||||
? (r.core_count || '?') + 'C / ' + (r.ram_size ? Math.round(r.ram_size/1024) + 'G' : '?') + ' / ' + (r.disk_size || '?') + 'GB'
|
||||
: '-';
|
||||
return '<tr>' +
|
||||
'<td><strong>' + (r.nsapp || '-') + '</strong></td>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + (r.status || '-') + '</span></td>' +
|
||||
'<td>' + (r.os_type || '-') + ' ' + (r.os_version || '') + '</td>' +
|
||||
'<td>' + (r.type || '-') + '</td>' +
|
||||
'<td>' + (r.method || 'default') + '</td>' +
|
||||
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></td>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(r.status || '-') + '</span></td>' +
|
||||
'<td>' + escapeHtml(r.os_type || '-') + ' ' + escapeHtml(r.os_version || '') + '</td>' +
|
||||
'<td>' + escapeHtml(r.type || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(r.method || 'default') + '</td>' +
|
||||
'<td>' + resources + '</td>' +
|
||||
'<td>' + (r.exit_code || '-') + '</td>' +
|
||||
'<td title="' + (r.error || '').replace(/"/g, '"') + '">' +
|
||||
((r.error || '').slice(0, 40) + (r.error && r.error.length > 40 ? '...' : '')) + '</td>' +
|
||||
'<td title="' + escapeHtml(r.error || '') + '">' +
|
||||
escapeHtml((r.error || '').slice(0, 40)) + (r.error && r.error.length > 40 ? '...' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterTable() {
|
||||
currentPage = 1;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (allRecords.length === 0) {
|
||||
alert('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['App', 'Status', 'OS Type', 'OS Version', 'Type', 'Method', 'Cores', 'RAM (MB)', 'Disk (GB)', 'Exit Code', 'Error', 'PVE Version'];
|
||||
const rows = allRecords.map(r => [
|
||||
r.nsapp || '',
|
||||
r.status || '',
|
||||
r.os_type || '',
|
||||
r.os_version || '',
|
||||
r.type || '',
|
||||
r.method || '',
|
||||
r.core_count || '',
|
||||
r.ram_size || '',
|
||||
r.disk_size || '',
|
||||
r.exit_code || '',
|
||||
(r.error || '').replace(/,/g, ';'),
|
||||
r.pve_version || ''
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'telemetry_' + new Date().toISOString().slice(0,10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
const data = await fetchData();
|
||||
|
||||
Reference in New Issue
Block a user