ProxmoxVED/misc/data/alerts.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

853 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log"
"net/smtp"
"strings"
"sync"
"time"
)
// AlertConfig holds SMTP alert configuration
type AlertConfig struct {
Enabled bool
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPTo []string
UseTLS bool
FailureThreshold float64 // Alert when failure rate exceeds this (e.g., 20.0 = 20%)
CheckInterval time.Duration // How often to check
Cooldown time.Duration // Minimum time between alerts
// Weekly Report settings
WeeklyReportEnabled bool // Enable weekly summary reports
WeeklyReportDay time.Weekday // Day to send report (0=Sunday, 1=Monday, etc.)
WeeklyReportHour int // Hour to send report (0-23)
}
// WeeklyReportData contains aggregated weekly statistics
type WeeklyReportData struct {
CalendarWeek int
Year int
StartDate time.Time
EndDate time.Time
TotalInstalls int
SuccessCount int
FailedCount int
SuccessRate float64
TopApps []AppStat
TopFailedApps []AppStat
ComparedToPrev WeekComparison
OsDistribution map[string]int
TypeDistribution map[string]int
}
// AppStat represents statistics for a single app
type AppStat struct {
Name string
Total int
Failed int
FailureRate float64
}
// WeekComparison shows changes compared to previous week
type WeekComparison struct {
InstallsChange int // Difference in total installs
InstallsPercent float64 // Percentage change
FailRateChange float64 // Change in failure rate (percentage points)
}
// Alerter handles alerting functionality
type Alerter struct {
cfg AlertConfig
lastAlertAt time.Time
lastWeeklyReport time.Time
mu sync.Mutex
pb *PBClient
lastStats alertStats
alertHistory []AlertEvent
}
type alertStats struct {
successCount int
failedCount int
checkedAt time.Time
}
// AlertEvent records an alert that was sent
type AlertEvent struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"`
Message string `json:"message"`
FailureRate float64 `json:"failure_rate,omitempty"`
}
// NewAlerter creates a new alerter instance
func NewAlerter(cfg AlertConfig, pb *PBClient) *Alerter {
return &Alerter{
cfg: cfg,
pb: pb,
alertHistory: make([]AlertEvent, 0),
}
}
// Start begins the alert monitoring loop
func (a *Alerter) Start() {
if !a.cfg.Enabled {
log.Println("INFO: alerting disabled")
return
}
if a.cfg.SMTPHost == "" || len(a.cfg.SMTPTo) == 0 {
log.Println("WARN: alerting enabled but SMTP not configured")
return
}
go a.monitorLoop()
log.Printf("INFO: alert monitoring started (threshold: %.1f%%, interval: %v)", a.cfg.FailureThreshold, a.cfg.CheckInterval)
// Start weekly report scheduler if enabled
if a.cfg.WeeklyReportEnabled {
go a.weeklyReportLoop()
log.Printf("INFO: weekly report scheduler started (day: %s, hour: %02d:00)", a.cfg.WeeklyReportDay, a.cfg.WeeklyReportHour)
}
}
func (a *Alerter) monitorLoop() {
ticker := time.NewTicker(a.cfg.CheckInterval)
defer ticker.Stop()
for range ticker.C {
a.checkAndAlert()
}
}
func (a *Alerter) checkAndAlert() {
ctx, cancel := newTimeoutContext(10 * time.Second)
defer cancel()
// Fetch last hour's data
data, err := a.pb.FetchDashboardData(ctx, 1)
if err != nil {
log.Printf("WARN: alert check failed: %v", err)
return
}
// Calculate current failure rate
total := data.SuccessCount + data.FailedCount
if total < 10 {
// Not enough data to determine rate
return
}
failureRate := float64(data.FailedCount) / float64(total) * 100
// Check if we should alert
if failureRate >= a.cfg.FailureThreshold {
a.maybeSendAlert(failureRate, data.FailedCount, total)
}
}
func (a *Alerter) maybeSendAlert(rate float64, failed, total int) {
a.mu.Lock()
defer a.mu.Unlock()
// Check cooldown
if time.Since(a.lastAlertAt) < a.cfg.Cooldown {
return
}
// Send alert
subject := fmt.Sprintf("[ProxmoxVED Alert] High Failure Rate: %.1f%%", rate)
body := fmt.Sprintf(`ProxmoxVE Helper Scripts - Telemetry Alert
⚠️ High installation failure rate detected!
Current Statistics (last 24h):
- Failure Rate: %.1f%%
- Failed Installations: %d
- Total Installations: %d
- Threshold: %.1f%%
Time: %s
Please check the dashboard for more details.
---
This is an automated alert from the telemetry service.
`, rate, failed, total, a.cfg.FailureThreshold, time.Now().Format(time.RFC1123))
if err := a.sendEmail(subject, body); err != nil {
log.Printf("ERROR: failed to send alert email: %v", err)
return
}
a.lastAlertAt = time.Now()
a.alertHistory = append(a.alertHistory, AlertEvent{
Timestamp: time.Now(),
Type: "high_failure_rate",
Message: fmt.Sprintf("Failure rate %.1f%% exceeded threshold %.1f%%", rate, a.cfg.FailureThreshold),
FailureRate: rate,
})
// Keep only last 100 alerts
if len(a.alertHistory) > 100 {
a.alertHistory = a.alertHistory[len(a.alertHistory)-100:]
}
log.Printf("ALERT: sent high failure rate alert (%.1f%%)", rate)
}
func (a *Alerter) sendEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/plain")
}
func (a *Alerter) sendHTMLEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/html")
}
func (a *Alerter) sendEmailWithType(subject, body, contentType string) error {
// Build message
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", a.cfg.SMTPFrom))
msg.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(a.cfg.SMTPTo, ", ")))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: %s; charset=UTF-8\r\n", contentType))
msg.WriteString("\r\n")
msg.WriteString(body)
addr := fmt.Sprintf("%s:%d", a.cfg.SMTPHost, a.cfg.SMTPPort)
var auth smtp.Auth
if a.cfg.SMTPUser != "" && a.cfg.SMTPPassword != "" {
auth = smtp.PlainAuth("", a.cfg.SMTPUser, a.cfg.SMTPPassword, a.cfg.SMTPHost)
}
if a.cfg.UseTLS {
// TLS connection
tlsConfig := &tls.Config{
ServerName: a.cfg.SMTPHost,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, a.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(a.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL failed: %w", err)
}
for _, to := range a.cfg.SMTPTo {
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT failed: %w", err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
return w.Close()
}
// Non-TLS (STARTTLS)
return smtp.SendMail(addr, auth, a.cfg.SMTPFrom, a.cfg.SMTPTo, msg.Bytes())
}
// GetAlertHistory returns recent alert events
func (a *Alerter) GetAlertHistory() []AlertEvent {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]AlertEvent, len(a.alertHistory))
copy(result, a.alertHistory)
return result
}
// TestAlert sends a test alert email
func (a *Alerter) TestAlert() error {
if !a.cfg.Enabled || a.cfg.SMTPHost == "" {
return fmt.Errorf("alerting not configured")
}
subject := "[ProxmoxVED] Test Alert"
body := fmt.Sprintf(`This is a test alert from ProxmoxVE Helper Scripts telemetry service.
If you received this email, your alert configuration is working correctly.
Time: %s
SMTP Host: %s
Recipients: %s
---
This is an automated test message.
`, time.Now().Format(time.RFC1123), a.cfg.SMTPHost, strings.Join(a.cfg.SMTPTo, ", "))
return a.sendEmail(subject, body)
}
// Helper for timeout context
func newTimeoutContext(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// weeklyReportLoop checks periodically if it's time to send the weekly report
func (a *Alerter) weeklyReportLoop() {
// Check every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
a.checkAndSendWeeklyReport()
}
}
// checkAndSendWeeklyReport sends the weekly report if it's the right time
func (a *Alerter) checkAndSendWeeklyReport() {
now := time.Now()
// Check if it's the right day and hour
if now.Weekday() != a.cfg.WeeklyReportDay || now.Hour() != a.cfg.WeeklyReportHour {
return
}
a.mu.Lock()
// Check if we already sent a report this week
_, lastWeek := a.lastWeeklyReport.ISOWeek()
_, currentWeek := now.ISOWeek()
if a.lastWeeklyReport.Year() == now.Year() && lastWeek == currentWeek {
a.mu.Unlock()
return
}
a.mu.Unlock()
// Send the weekly report
if err := a.SendWeeklyReport(); err != nil {
log.Printf("ERROR: failed to send weekly report: %v", err)
}
}
// SendWeeklyReport generates and sends the weekly summary email
func (a *Alerter) SendWeeklyReport() error {
if !a.cfg.Enabled || a.cfg.SMTPHost == "" {
return fmt.Errorf("alerting not configured")
}
ctx, cancel := newTimeoutContext(30 * time.Second)
defer cancel()
// Get data for the past week
reportData, err := a.fetchWeeklyReportData(ctx)
if err != nil {
return fmt.Errorf("failed to fetch weekly data: %w", err)
}
// Generate email content
subject := fmt.Sprintf("[ProxmoxVED] Weekly Report - Week %d, %d", reportData.CalendarWeek, reportData.Year)
body := a.generateWeeklyReportHTML(reportData)
if err := a.sendHTMLEmail(subject, body); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
a.mu.Lock()
a.lastWeeklyReport = time.Now()
a.alertHistory = append(a.alertHistory, AlertEvent{
Timestamp: time.Now(),
Type: "weekly_report",
Message: fmt.Sprintf("Weekly report KW %d/%d sent", reportData.CalendarWeek, reportData.Year),
})
a.mu.Unlock()
log.Printf("INFO: weekly report KW %d/%d sent successfully", reportData.CalendarWeek, reportData.Year)
return nil
}
// fetchWeeklyReportData collects data for the weekly report
func (a *Alerter) fetchWeeklyReportData(ctx context.Context) (*WeeklyReportData, error) {
// Calculate the previous week's date range (Mon-Sun)
now := time.Now()
// Find last Monday
daysToLastMonday := int(now.Weekday() - time.Monday)
if daysToLastMonday < 0 {
daysToLastMonday += 7
}
// Go back to the Monday of LAST week
lastMonday := now.AddDate(0, 0, -daysToLastMonday-7)
lastMonday = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
lastSunday := lastMonday.AddDate(0, 0, 6)
lastSunday = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
// Get calendar week
year, week := lastMonday.ISOWeek()
// Fetch current week's data (7 days)
currentData, err := a.pb.FetchDashboardData(ctx, 7)
if err != nil {
return nil, fmt.Errorf("failed to fetch current week data: %w", err)
}
// Fetch previous week's data for comparison (14 days, we'll compare)
prevData, err := a.pb.FetchDashboardData(ctx, 14)
if err != nil {
// Non-fatal, just log
log.Printf("WARN: could not fetch previous week data: %v", err)
prevData = nil
}
// Build report data
report := &WeeklyReportData{
CalendarWeek: week,
Year: year,
StartDate: lastMonday,
EndDate: lastSunday,
TotalInstalls: currentData.TotalInstalls,
SuccessCount: currentData.SuccessCount,
FailedCount: currentData.FailedCount,
OsDistribution: make(map[string]int),
TypeDistribution: make(map[string]int),
}
// Calculate success rate
if report.TotalInstalls > 0 {
report.SuccessRate = float64(report.SuccessCount) / float64(report.TotalInstalls) * 100
}
// Top 5 installed apps
for i, app := range currentData.TopApps {
if i >= 5 {
break
}
report.TopApps = append(report.TopApps, AppStat{
Name: app.App,
Total: app.Count,
})
}
// Top 5 failed apps
for i, app := range currentData.FailedApps {
if i >= 5 {
break
}
report.TopFailedApps = append(report.TopFailedApps, AppStat{
Name: app.App,
Total: app.TotalCount,
Failed: app.FailedCount,
FailureRate: app.FailureRate,
})
}
// OS distribution
for _, os := range currentData.OsDistribution {
report.OsDistribution[os.Os] = os.Count
}
// Type distribution (LXC vs VM)
for _, t := range currentData.TypeStats {
report.TypeDistribution[t.Type] = t.Count
}
// Calculate comparison to previous week
if prevData != nil {
// Previous week stats (subtract current from 14-day total)
prevInstalls := prevData.TotalInstalls - currentData.TotalInstalls
prevFailed := prevData.FailedCount - currentData.FailedCount
prevSuccess := prevData.SuccessCount - currentData.SuccessCount
if prevInstalls > 0 {
prevFailRate := float64(prevFailed) / float64(prevInstalls) * 100
currentFailRate := 100 - report.SuccessRate
report.ComparedToPrev.InstallsChange = report.TotalInstalls - prevInstalls
if prevInstalls > 0 {
report.ComparedToPrev.InstallsPercent = float64(report.TotalInstalls-prevInstalls) / float64(prevInstalls) * 100
}
report.ComparedToPrev.FailRateChange = currentFailRate - prevFailRate
_ = prevSuccess // suppress unused warning
}
}
return report, nil
}
// generateWeeklyReportHTML creates the HTML email body for the weekly report
func (a *Alerter) generateWeeklyReportHTML(data *WeeklyReportData) string {
var b strings.Builder
// HTML Email Template
b.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f6f9fc;padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.07);">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:32px 40px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:600;">📊 Weekly Telemetry Report</h1>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.85);font-size:14px;">ProxmoxVE Helper Scripts</p>
</td>
</tr>
<!-- Week Info -->
<tr>
<td style="padding:24px 40px 0;">
<table width="100%" style="background:#f8fafc;border-radius:8px;padding:16px;">
<tr>
<td style="padding:12px 16px;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Calendar Week</span><br>
<span style="color:#1e293b;font-size:20px;font-weight:600;">Week `)
b.WriteString(fmt.Sprintf("%d, %d", data.CalendarWeek, data.Year))
b.WriteString(`</span>
</td>
<td style="padding:12px 16px;text-align:right;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Period</span><br>
<span style="color:#1e293b;font-size:14px;">`)
b.WriteString(fmt.Sprintf("%s %s", data.StartDate.Format("Jan 02"), data.EndDate.Format("Jan 02, 2006")))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Stats Grid -->
<tr>
<td style="padding:24px 40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.TotalInstalls))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Total</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.SuccessCount))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Successful</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#dc2626;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.FailedCount))
b.WriteString(`</div>
<div style="color:#991b1b;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Failed</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#2563eb;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%.1f%%", data.SuccessRate))
b.WriteString(`</div>
<div style="color:#1e40af;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Success Rate</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
`)
// Week comparison
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
installIcon := "📈"
installColor := "#16a34a"
if data.ComparedToPrev.InstallsChange < 0 {
installIcon = "📉"
installColor = "#dc2626"
}
failIcon := "✅"
failColor := "#16a34a"
if data.ComparedToPrev.FailRateChange > 0 {
failIcon = "⚠️"
failColor = "#dc2626"
}
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<table width="100%" style="background:#fafafa;border-radius:8px;">
<tr>
<td style="padding:16px;border-right:1px solid #e5e7eb;">
<span style="font-size:12px;color:#64748b;">vs. Previous Week</span><br>
<span style="font-size:16px;color:`)
b.WriteString(installColor)
b.WriteString(`;">`)
b.WriteString(installIcon)
b.WriteString(fmt.Sprintf(" %+d installations (%.1f%%)", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(`</span>
</td>
<td style="padding:16px;">
<span style="font-size:12px;color:#64748b;">Failure Rate Change</span><br>
<span style="font-size:16px;color:`)
b.WriteString(failColor)
b.WriteString(`;">`)
b.WriteString(failIcon)
b.WriteString(fmt.Sprintf(" %+.1f percentage points", data.ComparedToPrev.FailRateChange))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
`)
}
// Top 5 Installed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🏆 Top 5 Installed Scripts</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopApps) > 0 {
for i, app := range data.TopApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#f8fafc"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="background:#e0e7ff;color:#4338ca;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">%d</span>
<span style="margin-left:12px;font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;color:#64748b;">%d installs</td>
</tr>`, bgColor, i+1, app.Name, app.Total))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#64748b;">No data available</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Top 5 Failed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">⚠️ Top 5 Scripts with Highest Failure Rates</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#fef2f2"
}
rateColor := "#dc2626"
if app.FailureRate < 20 {
rateColor = "#ea580c"
}
if app.FailureRate < 10 {
rateColor = "#ca8a04"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:center;color:#64748b;">%d / %d failed</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;">
<span style="background:%s;color:#ffffff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:600;">%.1f%%</span>
</td>
</tr>`, bgColor, app.Name, app.Failed, app.Total, rateColor, app.FailureRate))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#16a34a;">🎉 No failures this week!</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Type Distribution
if len(data.TypeDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">📦 Distribution by Type</h2>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
`)
for t, count := range data.TypeDistribution {
percent := float64(count) / float64(data.TotalInstalls) * 100
b.WriteString(fmt.Sprintf(`<td style="padding:8px;">
<div style="background:#f1f5f9;border-radius:8px;padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#475569;">%d</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">%s (%.1f%%)</div>
</div>
</td>`, count, strings.ToUpper(t), percent))
}
b.WriteString(`</tr>
</table>
</td>
</tr>
`)
}
// OS Distribution
if len(data.OsDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🐧 Top Operating Systems</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
// Sort OS by count
type osEntry struct {
name string
count int
}
var osList []osEntry
for name, count := range data.OsDistribution {
osList = append(osList, osEntry{name, count})
}
for i := 0; i < len(osList); i++ {
for j := i + 1; j < len(osList); j++ {
if osList[j].count > osList[i].count {
osList[i], osList[j] = osList[j], osList[i]
}
}
}
for i, os := range osList {
if i >= 5 {
break
}
percent := float64(os.count) / float64(data.TotalInstalls) * 100
barWidth := int(percent * 2) // Scale for visual
if barWidth > 100 {
barWidth = 100
}
b.WriteString(fmt.Sprintf(`<tr>
<td style="padding:8px 16px;width:100px;">%s</td>
<td style="padding:8px 16px;">
<div style="background:#e2e8f0;border-radius:4px;height:20px;width:100%%;">
<div style="background:linear-gradient(90deg,#667eea,#764ba2);border-radius:4px;height:20px;width:%d%%;"></div>
</div>
</td>
<td style="padding:8px 16px;text-align:right;width:80px;color:#64748b;">%d (%.1f%%)</td>
</tr>`, os.name, barWidth, os.count, percent))
}
b.WriteString(`</table>
</td>
</tr>
`)
}
// Footer
b.WriteString(`<tr>
<td style="padding:24px 40px;background:#f8fafc;border-radius:0 0 12px 12px;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#64748b;text-align:center;">
Generated `)
b.WriteString(time.Now().Format("Jan 02, 2006 at 15:04 MST"))
b.WriteString(`<br>
<a href="https://github.com/community-scripts/ProxmoxVE" style="color:#667eea;text-decoration:none;">ProxmoxVE Helper Scripts</a> —
This is an automated report from the telemetry service.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`)
return b.String()
}
// generateWeeklyReportEmail creates the plain text email body (kept for compatibility)
func (a *Alerter) generateWeeklyReportEmail(data *WeeklyReportData) string {
var b strings.Builder
b.WriteString("ProxmoxVE Helper Scripts - Weekly Telemetry Report\n")
b.WriteString("==================================================\n\n")
b.WriteString(fmt.Sprintf("Calendar Week: %d, %d\n", data.CalendarWeek, data.Year))
b.WriteString(fmt.Sprintf("Period: %s - %s\n\n",
data.StartDate.Format("Jan 02, 2006"),
data.EndDate.Format("Jan 02, 2006")))
b.WriteString("OVERVIEW\n")
b.WriteString("--------\n")
b.WriteString(fmt.Sprintf("Total Installations: %d\n", data.TotalInstalls))
b.WriteString(fmt.Sprintf("Successful: %d\n", data.SuccessCount))
b.WriteString(fmt.Sprintf("Failed: %d\n", data.FailedCount))
b.WriteString(fmt.Sprintf("Success Rate: %.1f%%\n\n", data.SuccessRate))
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
b.WriteString("vs. Previous Week:\n")
b.WriteString(fmt.Sprintf(" Installations: %+d (%.1f%%)\n", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(fmt.Sprintf(" Failure Rate: %+.1f pp\n\n", data.ComparedToPrev.FailRateChange))
}
b.WriteString("TOP 5 INSTALLED SCRIPTS\n")
b.WriteString("-----------------------\n")
for i, app := range data.TopApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-25s %5d installs\n", i+1, app.Name, app.Total))
}
b.WriteString("\n")
b.WriteString("TOP 5 FAILED SCRIPTS\n")
b.WriteString("--------------------\n")
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-20s %3d/%3d failed (%.1f%%)\n",
i+1, app.Name, app.Failed, app.Total, app.FailureRate))
}
} else {
b.WriteString("No failures this week!\n")
}
b.WriteString("\n")
b.WriteString("---\n")
b.WriteString(fmt.Sprintf("Generated: %s\n", time.Now().Format("Jan 02, 2006 15:04 MST")))
b.WriteString("This is an automated report from the telemetry service.\n")
return b.String()
}
// TestWeeklyReport sends a test weekly report email
func (a *Alerter) TestWeeklyReport() error {
return a.SendWeeklyReport()
}