ProxmoxVED/misc/data/dashboard.go
CanbiZ (MickLesk) d32b00ff31 Add weekly reports, cleanup, and dashboard UI
Introduce weekly summary reports and a cleanup job, enhance dashboard UI, and adjust telemetry/build settings.

- Add REPO_SOURCE to misc/api.func and include repo_source in telemetry payloads.
- Implement weekly report generation/scheduling in alerts.go: new data types, HTML/plain templates, scheduler, SendWeeklyReport/TestWeeklyReport, and email/HTML helpers.
- Add Cleaner (misc/data/cleanup.go) to detect and mark stuck installations as 'unknown' with scheduling and manual trigger APIs.
- Enhance dashboard backend/frontend (misc/data/dashboard.go): optional days filter (allow 'All'), increase fetch page size, simplify fetchRecords, add quick filter buttons, detail & health modals, improved styles and chart options, and client-side record detail view.
- Update Dockerfile (misc/data/Dockerfile): rename binaries to telemetry-service and build migrate from ./migration/migrate.go; copy adjusted in final image.
- Add migration tooling (misc/data/migration/migrate.sh and migration.go) and other small service changes.

These changes add operational reporting and cleanup capabilities, improve observability and UX of the dashboard, and align build and telemetry identifiers for the service.
2026-02-11 12:19:30 +01:00

2312 lines
76 KiB
Go

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
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 (days=0 means all entries)
var filter string
if days > 0 {
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02 00:00:00")
filter = url.QueryEscape(fmt.Sprintf("created >= '%s'", since))
} else {
filter = "" // No filter = all entries
}
// 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.devColl, filter, page, perPage)
} else {
url = fmt.Sprintf("%s/api/collections/%s/records?sort=-created&page=%d&perPage=%d",
p.baseURL, p.devColl, 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-yellow: #d29922;
--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;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.header h1 {
font-size: 24px;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.header h1 img {
height: 40px;
}
.controls {
display: flex;
gap: 12px;
align-items: center;
}
select, button {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
select:hover, button:hover {
border-color: var(--accent-blue);
}
button {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.quickfilter {
display: flex;
gap: 4px;
background: var(--bg-tertiary);
padding: 4px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.filter-btn {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.filter-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent-blue);
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.stat-card .label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 600;
}
.stat-card .value.success { color: var(--accent-green); }
.stat-card .value.failed { color: var(--accent-red); }
.stat-card .value.pending { color: var(--accent-yellow); }
.stat-card .value.rate { color: var(--accent-blue); }
.charts-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1200px) {
.charts-grid {
grid-template-columns: 1fr;
}
}
.chart-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.chart-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.chart-container {
position: relative;
height: 300px;
}
.small-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.table-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.table-card h3 {
font-size: 14px;
font-weight: 600;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
}
.filters {
padding: 12px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.filters input, .filters select {
background: var(--bg-secondary);
flex: 1;
min-width: 150px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 20px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
background: var(--bg-tertiary);
}
td {
font-size: 14px;
}
tr:hover {
background: var(--bg-tertiary);
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--accent-green); }
.status-badge.failed { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); }
.status-badge.installing { background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); }
th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
th.sortable:hover {
background: rgba(88, 166, 255, 0.1);
}
th.sort-asc, th.sort-desc {
background: rgba(88, 166, 255, 0.15);
color: var(--accent-blue);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
color: var(--text-secondary);
}
.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--accent-red);
color: var(--accent-red);
padding: 16px;
border-radius: 8px;
margin-bottom: 24px;
}
.last-updated {
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);
}
.admin-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-left: 8px;
}
.admin-btn:hover {
background: var(--accent-blue);
color: #fff;
border-color: var(--accent-blue);
}
.admin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.footer-btn {
background: transparent;
border: none;
color: var(--accent-blue);
cursor: pointer;
font-size: 12px;
padding: 0;
margin-right: 8px;
}
.footer-btn:hover {
text-decoration: underline;
}
.health-modal {
max-width: 400px;
}
.health-status {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.health-status.ok {
background: rgba(63, 185, 80, 0.1);
border: 1px solid var(--accent-green);
}
.health-status.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--accent-red);
}
.health-status .icon {
font-size: 32px;
}
.health-status .details {
flex: 1;
}
.health-status .title {
font-weight: 600;
font-size: 16px;
}
.health-status .subtitle {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.health-info {
font-size: 12px;
color: var(--text-secondary);
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.health-info div {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.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;
}
/* Detail Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9);
transition: transform 0.2s;
}
.modal-overlay.active .modal-content {
transform: scale(1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 10;
}
.modal-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.detail-section-header svg {
opacity: 0.7;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.detail-item {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px 16px;
}
.detail-item .label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.detail-item .value {
font-size: 15px;
font-weight: 500;
word-break: break-word;
}
.detail-item .value.mono {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item .value.status-success { color: var(--accent-green); }
.detail-item .value.status-failed { color: var(--accent-red); }
.detail-item .value.status-installing { color: var(--accent-yellow); }
.error-box {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 8px;
padding: 16px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--accent-red);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
tr.clickable-row {
cursor: pointer;
transition: background 0.15s;
}
tr.clickable-row:hover {
background: rgba(88, 166, 255, 0.1) !important;
}
</style>
</head>
<body>
<div class="header">
<h1>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3"/>
</svg>
Telemetry Dashboard
</h1>
<div class="controls">
<div class="quickfilter">
<button class="filter-btn" data-days="7">7 Days</button>
<button class="filter-btn active" data-days="30">30 Days</button>
<button class="filter-btn" data-days="90">90 Days</button>
<button class="filter-btn" data-days="365">1 Year</button>
<button class="filter-btn" data-days="0">All</button>
</div>
<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>
<div id="error" class="error" style="display: none;"></div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">Total Installations</div>
<div class="value" id="totalInstalls">-</div>
</div>
<div class="stat-card">
<div class="label">Successful</div>
<div class="value success" id="successCount">-</div>
</div>
<div class="stat-card">
<div class="label">Failed</div>
<div class="value failed" id="failedCount">-</div>
</div>
<div class="stat-card">
<div class="label">In Progress</div>
<div class="value pending" id="installingCount">-</div>
</div>
<div class="stat-card">
<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">
<div class="chart-card">
<h3>Installations Over Time</h3>
<div class="chart-container">
<canvas id="dailyChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Status Distribution</h3>
<div class="chart-container">
<canvas id="statusChart"></canvas>
</div>
</div>
</div>
<div class="small-charts">
<div class="chart-card">
<h3>Top Applications</h3>
<div class="chart-container">
<canvas id="appsChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>OS Distribution</h3>
<div class="chart-container">
<canvas id="osChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Installation Method</h3>
<div class="chart-container">
<canvas id="methodChart"></canvas>
</div>
</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">
<input type="text" id="filterApp" placeholder="Filter by app..." oninput="filterTable()">
<select id="filterStatus" onchange="filterTable()">
<option value="">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="installing">Installing</option>
</select>
<select id="filterOs" onchange="filterTable()">
<option value="">All OS</option>
</select>
</div>
<table id="installTable">
<thead>
<tr>
<th data-sort="nsapp" class="sortable">App</th>
<th data-sort="status" class="sortable">Status</th>
<th data-sort="os_type" class="sortable">OS</th>
<th data-sort="type" class="sortable">Type</th>
<th data-sort="method" class="sortable">Method</th>
<th>Resources</th>
<th data-sort="exit_code" class="sortable">Exit Code</th>
<th>Error</th>
<th data-sort="created" class="sortable sort-desc">Created ▼</th>
</tr>
</thead>
<tbody id="recordsTable">
<tr><td colspan="9" 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>
&bull; Telemetry is anonymous and privacy-friendly
</div>
<div>
<button class="footer-btn" onclick="showHealthCheck()">Health Check</button>
<a href="/api/dashboard" target="_blank">API</a>
</div>
</div>
<!-- Health Check Modal -->
<div class="modal-overlay" id="healthModal" onclick="closeHealthModal(event)">
<div class="modal-content health-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>🏥 Health Check</h2>
<button class="modal-close" onclick="closeHealthModal()">&times;</button>
</div>
<div class="modal-body" id="healthModalBody">
<div class="loading">Checking...</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="modal-overlay" id="detailModal" onclick="closeModalOutside(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 id="modalTitle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="9" x2="15" y2="9"/>
<line x1="9" y1="13" x2="15" y2="13"/>
<line x1="9" y1="17" x2="11" y2="17"/>
</svg>
<span>Record Details</span>
</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- Content filled by JavaScript -->
</div>
</div>
</div>
<script>
let charts = {};
let allRecords = [];
let currentPage = 1;
let totalPages = 1;
let currentTheme = localStorage.getItem('theme') || 'dark';
let currentSort = { field: 'created', dir: 'desc' };
// 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)',
green: 'rgba(63, 185, 80, 0.8)',
red: 'rgba(248, 81, 73, 0.8)',
yellow: 'rgba(210, 153, 34, 0.8)',
purple: 'rgba(163, 113, 247, 0.8)',
gray: 'rgba(139, 148, 158, 0.8)'
};
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c9d1d9' }
}
},
scales: {
x: {
ticks: { color: '#8b949e' },
grid: { color: '#30363d' }
},
y: {
ticks: { color: '#8b949e' },
grid: { color: '#30363d' }
}
}
};
async function fetchData() {
const activeBtn = document.querySelector('.filter-btn.active');
const days = activeBtn ? activeBtn.dataset.days : '30';
try {
const response = await fetch('/api/dashboard?days=' + days);
if (!response.ok) throw new Error('Failed to fetch data');
return await response.json();
} catch (error) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Error: ' + error.message;
throw error;
}
}
function updateStats(data) {
document.getElementById('totalInstalls').textContent = data.total_installs.toLocaleString();
document.getElementById('successCount').textContent = data.success_count.toLocaleString();
document.getElementById('failedCount').textContent = data.failed_count.toLocaleString();
document.getElementById('installingCount').textContent = data.installing_count.toLocaleString();
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatTimestamp(ts) {
if (!ts) return '-';
const d = new Date(ts);
const now = new Date();
const diff = now - d;
// Less than 1 minute ago
if (diff < 60000) return 'just now';
// Less than 1 hour ago
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
// Less than 24 hours ago
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
// Less than 7 days ago
if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
// Older - show date
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function initSortableHeaders() {
document.querySelectorAll('th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => sortByColumn(th.dataset.sort));
});
}
function sortByColumn(field) {
// Toggle direction if same field
if (currentSort.field === field) {
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
currentSort.dir = 'desc';
}
// Update header indicators
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
const arrow = th.textContent.replace(/[▲▼]/g, '').trim();
th.textContent = arrow;
});
const activeTh = document.querySelector('th[data-sort=\"' + field + '\"]');
if (activeTh) {
activeTh.classList.add(currentSort.dir === 'asc' ? 'sort-asc' : 'sort-desc');
activeTh.textContent = activeTh.textContent + ' ' + (currentSort.dir === 'asc' ? '▲' : '▼');
}
// Re-fetch with new sort
currentPage = 1;
fetchPaginatedRecords();
}
function updateCharts(data) {
// Daily chart
if (charts.daily) charts.daily.destroy();
charts.daily = new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: data.daily_stats.map(d => d.date.slice(5)), // MM-DD
datasets: [
{
label: 'Success',
data: data.daily_stats.map(d => d.success),
borderColor: chartColors.green,
backgroundColor: 'rgba(63, 185, 80, 0.1)',
fill: true,
tension: 0.3
},
{
label: 'Failed',
data: data.daily_stats.map(d => d.failed),
borderColor: chartColors.red,
backgroundColor: 'rgba(248, 81, 73, 0.1)',
fill: true,
tension: 0.3
}
]
},
options: chartDefaults
});
// Status pie chart
if (charts.status) charts.status.destroy();
charts.status = new Chart(document.getElementById('statusChart'), {
type: 'doughnut',
data: {
labels: ['Success', 'Failed', 'Installing'],
datasets: [{
data: [data.success_count, data.failed_count, data.installing_count],
backgroundColor: [chartColors.green, chartColors.red, chartColors.yellow]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#c9d1d9' }
}
}
}
});
// Top apps chart
if (charts.apps) charts.apps.destroy();
charts.apps = new Chart(document.getElementById('appsChart'), {
type: 'bar',
data: {
labels: data.top_apps.map(a => a.app),
datasets: [{
label: 'Installations',
data: data.top_apps.map(a => a.count),
backgroundColor: chartColors.blue
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#8b949e',
stepSize: 1,
callback: function(value) { return Number.isInteger(value) ? value : ''; }
},
grid: { color: '#30363d' }
},
y: {
ticks: { color: '#8b949e' },
grid: { color: '#30363d' }
}
}
}
});
// OS distribution chart
if (charts.os) charts.os.destroy();
charts.os = new Chart(document.getElementById('osChart'), {
type: 'pie',
data: {
labels: data.os_distribution.map(o => o.os),
datasets: [{
data: data.os_distribution.map(o => o.count),
backgroundColor: [
chartColors.blue, chartColors.green, chartColors.purple,
chartColors.yellow, chartColors.red, chartColors.gray
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#c9d1d9' }
}
}
}
});
// Method chart
if (charts.method) charts.method.destroy();
charts.method = new Chart(document.getElementById('methodChart'), {
type: 'bar',
data: {
labels: data.method_stats.map(m => m.method || 'default'),
datasets: [{
label: 'Count',
data: data.method_stats.map(m => m.count),
backgroundColor: chartColors.purple
}]
},
options: {
...chartDefaults,
plugins: { legend: { display: false } }
}
});
}
function updateTable(records) {
allRecords = records || [];
// Populate OS filter
const osFilter = document.getElementById('filterOs');
const uniqueOs = [...new Set(allRecords.map(r => r.os_type).filter(Boolean))];
osFilter.innerHTML = '<option value="">All OS</option>' +
uniqueOs.map(os => '<option value="' + os + '">' + os + '</option>').join('');
filterTable();
}
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);
if (currentSort.field) {
url += '&sort=' + (currentSort.dir === 'desc' ? '-' : '') + currentSort.field;
}
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();
}
}
// Store current records for detail view
let currentRecords = [];
function renderTableRows(records) {
const tbody = document.getElementById('recordsTable');
currentRecords = records; // Store for detail modal
if (records.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="loading">No records found</td></tr>';
return;
}
tbody.innerHTML = records.map((r, index) => {
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'
: '-';
const created = r.created ? formatTimestamp(r.created) : '-';
return '<tr class="clickable-row" onclick="showRecordDetail(' + index + ')">' +
'<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="' + escapeHtml(r.error || '') + '">' +
escapeHtml((r.error || '').slice(0, 40)) + (r.error && r.error.length > 40 ? '...' : '') + '</td>' +
'<td title="' + escapeHtml(r.created || '') + '">' + created + '</td>' +
'</tr>';
}).join('');
}
function showRecordDetail(index) {
const record = currentRecords[index];
if (!record) return;
const modal = document.getElementById('detailModal');
const modalTitle = document.getElementById('modalTitle').querySelector('span');
const modalBody = document.getElementById('modalBody');
modalTitle.textContent = record.nsapp || 'Record Details';
// Build detail content with sections
let html = '';
// General Information Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><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="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> General Information</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('App Name', record.nsapp);
html += buildDetailItem('Status', record.status, 'status-' + (record.status || 'unknown'));
html += buildDetailItem('Type', formatType(record.type));
html += buildDetailItem('Method', record.method || 'default');
html += buildDetailItem('Random ID', record.random_id, 'mono');
html += '</div></div>';
// System Resources Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg> System Resources</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('CPU Cores', record.core_count ? record.core_count + ' Cores' : null);
html += buildDetailItem('RAM', record.ram_size ? formatBytes(record.ram_size * 1024 * 1024) : null);
html += buildDetailItem('Disk Size', record.disk_size ? record.disk_size + ' GB' : null);
html += buildDetailItem('CT Type', record.ct_type !== undefined ? (record.ct_type === 1 ? 'Unprivileged' : 'Privileged') : null);
html += '</div></div>';
// Operating System Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> Operating System</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('OS Type', record.os_type);
html += buildDetailItem('OS Version', record.os_version);
html += buildDetailItem('PVE Version', record.pve_version);
html += '</div></div>';
// Hardware Section (CPU & GPU)
const hasHardwareInfo = record.cpu_vendor || record.cpu_model || record.gpu_vendor || record.gpu_model || record.ram_speed;
if (hasHardwareInfo) {
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Hardware</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('CPU Vendor', record.cpu_vendor);
html += buildDetailItem('CPU Model', record.cpu_model);
html += buildDetailItem('RAM Speed', record.ram_speed);
html += buildDetailItem('GPU Vendor', record.gpu_vendor);
html += buildDetailItem('GPU Model', record.gpu_model);
html += buildDetailItem('GPU Passthrough', formatPassthrough(record.gpu_passthrough));
html += '</div></div>';
}
// Installation Details Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Installation</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('Exit Code', record.exit_code !== undefined ? record.exit_code : null, record.exit_code === 0 ? 'status-success' : (record.exit_code ? 'status-failed' : ''));
html += buildDetailItem('Duration', record.install_duration ? formatDuration(record.install_duration) : null);
html += buildDetailItem('Error Category', record.error_category);
html += '</div></div>';
// Error Section (if present)
if (record.error) {
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><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="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Error Details</div>';
html += '<div class="error-box">' + escapeHtml(record.error) + '</div>';
html += '</div>';
}
// Timestamps Section
html += '<div class="detail-section">';
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Timestamps</div>';
html += '<div class="detail-grid">';
html += buildDetailItem('Created', formatFullTimestamp(record.created));
html += buildDetailItem('Updated', formatFullTimestamp(record.updated));
html += '</div></div>';
modalBody.innerHTML = html;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
function buildDetailItem(label, value, extraClass) {
if (value === null || value === undefined || value === '') {
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="value" style="color: var(--text-secondary);">—</div></div>';
}
const valueClass = extraClass ? 'value ' + extraClass : 'value';
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="' + valueClass + '">' + escapeHtml(String(value)) + '</div></div>';
}
function formatType(type) {
if (!type) return null;
const types = {
'lxc': 'LXC Container',
'vm': 'Virtual Machine',
'addon': 'Add-on',
'pve': 'Proxmox VE',
'tool': 'Tool'
};
return types[type.toLowerCase()] || type;
}
function formatPassthrough(pt) {
if (!pt) return null;
const modes = {
'igpu': 'Integrated GPU',
'dgpu': 'Dedicated GPU',
'vgpu': 'Virtual GPU',
'none': 'None',
'unknown': 'Unknown'
};
return modes[pt.toLowerCase()] || pt;
}
function formatBytes(bytes) {
if (!bytes) return null;
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return gb.toFixed(1) + ' GB';
const mb = bytes / (1024 * 1024);
return mb.toFixed(0) + ' MB';
}
function formatDuration(seconds) {
if (!seconds) return null;
if (seconds < 60) return seconds + 's';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins < 60) return mins + 'm ' + secs + 's';
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
return hours + 'h ' + remainMins + 'm';
}
function formatFullTimestamp(ts) {
if (!ts) return null;
const d = new Date(ts);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
}
function closeModal() {
const modal = document.getElementById('detailModal');
modal.classList.remove('active');
document.body.style.overflow = '';
}
function closeModalOutside(event) {
if (event.target === document.getElementById('detailModal')) {
closeModal();
}
}
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
closeHealthModal();
}
});
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 showHealthCheck() {
const modal = document.getElementById('healthModal');
const body = document.getElementById('healthModalBody');
body.innerHTML = '<div class="loading">Checking...</div>';
modal.classList.add('active');
document.body.style.overflow = 'hidden';
try {
const resp = await fetch('/healthz');
const data = await resp.json();
const isOk = data.status === 'ok';
const statusClass = isOk ? 'ok' : 'error';
const icon = isOk ? '✅' : '❌';
const title = isOk ? 'All Systems Operational' : 'Service Degraded';
let html = '<div class="health-status ' + statusClass + '">';
html += '<span class="icon">' + icon + '</span>';
html += '<div class="details">';
html += '<div class="title">' + title + '</div>';
html += '<div class="subtitle">Last checked: ' + new Date().toLocaleTimeString() + '</div>';
html += '</div></div>';
html += '<div class="health-info">';
html += '<div><span>Status</span><span>' + data.status + '</span></div>';
html += '<div><span>Server Time</span><span>' + new Date(data.time).toLocaleString() + '</span></div>';
if (data.pocketbase) {
html += '<div><span>PocketBase</span><span>' + (data.pocketbase === 'connected' ? '🟢 Connected' : '🔴 ' + data.pocketbase) + '</span></div>';
}
if (data.version) {
html += '<div><span>Version</span><span>' + data.version + '</span></div>';
}
html += '</div>';
body.innerHTML = html;
} catch (e) {
body.innerHTML = '<div class="health-status error"><span class="icon">❌</span><div class="details"><div class="title">Connection Failed</div><div class="subtitle">' + e.message + '</div></div></div>';
}
}
function closeHealthModal(event) {
if (event && event.target !== document.getElementById('healthModal')) return;
document.getElementById('healthModal').classList.remove('active');
document.body.style.overflow = '';
}
async function refreshData() {
try {
const data = await fetchData();
updateStats(data);
updateCharts(data);
updateTable(data.recent_records);
} catch (e) {
console.error(e);
}
}
// Initial load
refreshData();
initSortableHeaders();
// Quickfilter button clicks
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
refreshData();
});
});
// Auto-refresh every 60 seconds
setInterval(refreshData, 60000);
</script>
</body>
</html>`
}