From a58b7682f3b148571143fcb2499b56df4b5f1179 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 18:08:00 -0500 Subject: [PATCH 01/13] feat(core): add uptime monitoring and command history --- internal/app/app.go | 2 + internal/config/config.go | 2 +- internal/db/db.go | 42 +++ internal/handlers/handlers.go | 272 +++++++++++++- internal/models/models.go | 56 +++ internal/services/node.go | 519 ++++++++++++++++++++++++++- internal/views/pages/settings.gohtml | 77 ++-- internal/views/pages/uptime.gohtml | 182 ++++++++++ 8 files changed, 1091 insertions(+), 61 deletions(-) create mode 100644 internal/views/pages/uptime.gohtml diff --git a/internal/app/app.go b/internal/app/app.go index 86b3d3b..f76f767 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,6 +96,7 @@ func New() (*App, error) { protected.Get("/nodes/{nodeID}/console/ws", handler.NodeConsoleWebSocket) protected.Get("/groups", handler.GroupsPage) protected.Get("/automations", handler.AutomationsPage) + protected.Get("/uptime", handler.UptimePage) protected.Get("/settings", handler.SettingsPage) protected.Post("/settings/theme", handler.UpdateTheme) @@ -107,6 +108,7 @@ func New() (*App, error) { editor.Post("/nodes/{nodeID}/commands", handler.NodeQuickCommand) editor.Post("/nodes/{nodeID}/delete", handler.DeleteNode) editor.Post("/automations", handler.CreateAutomation) + editor.Post("/uptime/run", handler.RunUptimeChecks) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index d0a6a93..bc5892f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,7 @@ func Load() Config { EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"), OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"), BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"), - DefaultTheme: env("MAINTAINARR_THEME", "blue"), + DefaultTheme: env("MAINTAINARR_THEME", "dark"), DefaultMode: env("MAINTAINARR_THEME_MODE", "dark"), RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5s"), } diff --git a/internal/db/db.go b/internal/db/db.go index cb8ce09..cb89734 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -127,6 +127,7 @@ func migrate(ctx context.Context, database *sql.DB) error { job_id INTEGER, node_id INTEGER NOT NULL, action TEXT NOT NULL, + command_text TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, output TEXT NOT NULL DEFAULT '', triggered_by INTEGER, @@ -136,6 +137,44 @@ func migrate(ctx context.Context, database *sql.DB) error { FOREIGN KEY (node_id) REFERENCES nodes(id), FOREIGN KEY (triggered_by) REFERENCES users(id) );`, + `CREATE TABLE IF NOT EXISTS uptime_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL, + node_id INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL, + target TEXT NOT NULL, + monitor_type TEXT NOT NULL DEFAULT 'ssh', + interval_seconds INTEGER NOT NULL DEFAULT 60, + enabled BOOLEAN NOT NULL DEFAULT 1, + last_status TEXT NOT NULL DEFAULT 'unknown', + last_latency_ms INTEGER NOT NULL DEFAULT 0, + last_checked_at DATETIME, + last_error TEXT NOT NULL DEFAULT '', + up_since_at DATETIME, + current_outage_started_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id), + FOREIGN KEY (node_id) REFERENCES nodes(id) + );`, + `CREATE TABLE IF NOT EXISTS uptime_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + status TEXT NOT NULL, + latency_ms INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (monitor_id) REFERENCES uptime_monitors(id) + );`, + `CREATE TABLE IF NOT EXISTS uptime_incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + error_message TEXT NOT NULL DEFAULT '', + started_at DATETIME NOT NULL, + ended_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (monitor_id) REFERENCES uptime_monitors(id) + );`, } for _, statement := range statements { @@ -158,6 +197,9 @@ func migrate(ctx context.Context, database *sql.DB) error { `ALTER TABLE nodes ADD COLUMN memory_total_mb INTEGER NOT NULL DEFAULT 0;`, `ALTER TABLE nodes ADD COLUMN disk_total_gb INTEGER NOT NULL DEFAULT 0;`, `ALTER TABLE automation_jobs ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE command_runs ADD COLUMN command_text TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE uptime_monitors ADD COLUMN last_error TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE uptime_monitors ADD COLUMN up_since_at DATETIME;`, } for _, statement := range alterStatements { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e23ca66..05cb05e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -57,6 +57,7 @@ type settingsData struct { ThemeVariables template.CSS CurrentTheme string CurrentMode string + Runs []models.CommandRun } type jobsPageData struct { @@ -67,6 +68,39 @@ type jobsPageData struct { Runs []models.CommandRun } +type uptimePageData struct { + Summary uptimeSummary + Periods []uptimePeriodRow + Monitors []uptimeMonitorCard + Incidents []models.UptimeIncident +} + +type uptimeSummary struct { + TotalMonitors int + UpMonitors int + DownMonitors int + AvgLatencyMS int64 +} + +type uptimePeriodRow struct { + Label string + AvailabilityText string + DowntimeText string + Incidents int64 + LongestText string + AverageText string +} + +type uptimeMonitorCard struct { + Monitor models.UptimeMonitor + Availability float64 + AvailabilityText string + LastCheckedText string + StateDurationText string + IntervalText string + RecentChecks []models.UptimeCheck +} + func New(repo *services.Repository, auth *services.AuthService, sessions *services.SessionService, nodes *services.NodeService, renderer *views.Renderer, org models.Organization, baseURL string) *Handler { return &Handler{ repo: repo, @@ -336,11 +370,16 @@ func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to create node", http.StatusInternalServerError) return } + if err := h.nodes.EnsureUptimeMonitorForNode(r.Context(), node); err != nil { + http.Error(w, "failed to create node monitor", http.StatusInternalServerError) + return + } if node.SSHUsername != "" && node.SSHPassword != "" { _, _ = h.nodes.RefreshNodeInventory(r.Context(), node) _, _ = h.nodes.RefreshNodeStats(r.Context(), node) } + _ = h.nodes.RunAllUptimeChecks(r.Context(), h.org.ID) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } @@ -710,13 +749,33 @@ func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) { func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) org := h.currentOrganization(r.Context()) + currentMode := normalizeMode(org.ThemeMode) + currentTheme := normalizeTheme(org.Theme, currentMode) h.render(w, r, "settings", "Theme System", settingsData{ ThemeVariables: template.CSS(themePreview), - CurrentTheme: org.Theme, - CurrentMode: org.ThemeMode, + CurrentTheme: currentTheme, + CurrentMode: currentMode, + Runs: h.settingsRuns(r.Context()), }, user) } +func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user) +} + +func (h *Handler) RunUptimeChecks(w http.ResponseWriter, r *http.Request) { + if err := h.nodes.EnsureUptimeMonitors(r.Context(), h.org.ID); err != nil { + http.Error(w, "failed to sync monitors", http.StatusInternalServerError) + return + } + if err := h.nodes.RunAllUptimeChecks(r.Context(), h.org.ID); err != nil { + http.Error(w, "failed to run uptime checks", http.StatusBadGateway) + return + } + http.Redirect(w, r, "/uptime", http.StatusSeeOther) +} + func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -725,6 +784,9 @@ func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) { theme := strings.TrimSpace(r.FormValue("theme")) mode := strings.TrimSpace(r.FormValue("mode")) + if mode == "" { + mode = theme + } if !isAllowedTheme(theme) || !isAllowedMode(mode) { http.Error(w, "invalid theme selection", http.StatusBadRequest) return @@ -750,12 +812,14 @@ func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title str org := h.currentOrganization(r.Context()) groups, _ := h.repo.ListGroups(r.Context(), org.ID) tags, _ := h.repo.ListTags(r.Context(), org.ID) + mode := normalizeMode(org.ThemeMode) + theme := normalizeTheme(org.Theme, mode) h.renderer.Render(w, page, views.ViewData{ Title: title, Shell: shellForPage(page), - ThemeClass: org.Theme, - ThemeMode: org.ThemeMode, + ThemeClass: theme, + ThemeMode: mode, CurrentPath: r.URL.Path, User: currentUser, Organization: org, @@ -797,6 +861,75 @@ func (h *Handler) dashboardData(ctx context.Context) dashboardData { } } +func (h *Handler) uptimePageData(ctx context.Context) uptimePageData { + _ = h.nodes.EnsureUptimeMonitors(ctx, h.org.ID) + + monitors, _ := h.nodes.ListUptimeMonitors(ctx, h.org.ID) + checksByMonitor, _ := h.nodes.ListRecentUptimeChecks(ctx, h.org.ID, 24) + incidents, _ := h.nodes.ListRecentUptimeIncidents(ctx, h.org.ID, 20) + + summary := uptimeSummary{ + TotalMonitors: len(monitors), + } + var latencyTotal int64 + var latencyCount int64 + cards := make([]uptimeMonitorCard, 0, len(monitors)) + for _, monitor := range monitors { + if monitor.LastStatus == "down" { + summary.DownMonitors++ + } else if monitor.LastStatus == "up" { + summary.UpMonitors++ + } + if monitor.LastStatus == "up" && monitor.LastLatencyMS > 0 { + latencyTotal += monitor.LastLatencyMS + latencyCount++ + } + + recentChecks := checksByMonitor[monitor.ID] + card := uptimeMonitorCard{ + Monitor: monitor, + Availability: availabilityForChecks(recentChecks), + AvailabilityText: fmt.Sprintf("%.2f%%", availabilityForChecks(recentChecks)), + LastCheckedText: relativeTime(monitor.LastCheckedAt), + StateDurationText: monitorStateDuration(monitor), + IntervalText: humanizeInterval(monitor.IntervalSeconds), + RecentChecks: reverseChecks(recentChecks), + } + cards = append(cards, card) + } + if latencyCount > 0 { + summary.AvgLatencyMS = latencyTotal / latencyCount + } + + now := time.Now() + periods := []uptimePeriodRow{ + h.periodRow(ctx, "Today", ptrTime(now.Add(-24*time.Hour))), + h.periodRow(ctx, "Last 7 days", ptrTime(now.Add(-7*24*time.Hour))), + h.periodRow(ctx, "Last 30 days", ptrTime(now.Add(-30*24*time.Hour))), + h.periodRow(ctx, "Last 365 days", ptrTime(now.Add(-365*24*time.Hour))), + h.periodRow(ctx, "All time", nil), + } + + return uptimePageData{ + Summary: summary, + Periods: periods, + Monitors: cards, + Incidents: incidents, + } +} + +func (h *Handler) periodRow(ctx context.Context, label string, since *time.Time) uptimePeriodRow { + summary, _ := h.nodes.UptimePeriodSummary(ctx, h.org.ID, since) + return uptimePeriodRow{ + Label: label, + AvailabilityText: availabilityText(summary), + DowntimeText: humanizeSeconds(summary.DowntimeSeconds), + Incidents: summary.IncidentCount, + LongestText: humanizeSeconds(summary.LongestIncidentSeconds), + AverageText: humanizeSeconds(summary.AvgIncidentSeconds), + } +} + func (h *Handler) currentOrganization(ctx context.Context) models.Organization { org, err := h.repo.GetOrganization(ctx) if err != nil { @@ -805,6 +938,109 @@ func (h *Handler) currentOrganization(ctx context.Context) models.Organization { return org } +func availabilityForChecks(checks []models.UptimeCheck) float64 { + if len(checks) == 0 { + return 0 + } + var up int + for _, check := range checks { + if check.Status == "up" { + up++ + } + } + return float64(up) * 100 / float64(len(checks)) +} + +func availabilityText(summary models.UptimePeriodSummary) string { + if summary.TotalChecks == 0 { + return "0.00%" + } + return fmt.Sprintf("%.2f%%", float64(summary.UpChecks)*100/float64(summary.TotalChecks)) +} + +func relativeTime(value *time.Time) string { + if value == nil { + return "Never" + } + diff := time.Since(*value).Round(time.Second) + if diff < time.Minute { + seconds := int(diff.Seconds()) + if seconds < 1 { + seconds = 1 + } + return fmt.Sprintf("%ds ago", seconds) + } + if diff < time.Hour { + return fmt.Sprintf("%dm ago", int(diff.Minutes())) + } + if diff < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(diff.Hours())) + } + return value.Format("2006-01-02 15:04") +} + +func humanizeInterval(seconds int64) string { + if seconds <= 0 { + return "-" + } + if seconds%3600 == 0 { + hours := seconds / 3600 + if hours == 1 { + return "Every hour" + } + return fmt.Sprintf("Every %d hours", hours) + } + if seconds%60 == 0 { + minutes := seconds / 60 + if minutes == 1 { + return "Every minute" + } + return fmt.Sprintf("Every %d minutes", minutes) + } + return fmt.Sprintf("Every %d seconds", seconds) +} + +func monitorStateDuration(monitor models.UptimeMonitor) string { + if monitor.LastStatus == "up" && monitor.UpSinceAt != nil { + return "Up for " + humanizeSeconds(int64(time.Since(*monitor.UpSinceAt).Seconds())) + } + if monitor.LastStatus == "down" && monitor.CurrentOutageStartedAt != nil { + return "Down for " + humanizeSeconds(int64(time.Since(*monitor.CurrentOutageStartedAt).Seconds())) + } + return "Awaiting checks" +} + +func humanizeSeconds(seconds int64) string { + if seconds <= 0 { + return "-" + } + duration := time.Duration(seconds) * time.Second + days := int(duration / (24 * time.Hour)) + duration -= time.Duration(days) * 24 * time.Hour + hours := int(duration / time.Hour) + duration -= time.Duration(hours) * time.Hour + minutes := int(duration / time.Minute) + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} + +func ptrTime(value time.Time) *time.Time { + return &value +} + +func reverseChecks(checks []models.UptimeCheck) []models.UptimeCheck { + reversed := make([]models.UptimeCheck, len(checks)) + for i := range checks { + reversed[len(checks)-1-i] = checks[i] + } + return reversed +} + func shellForPage(page string) string { switch page { case "login", "login_otp", "register": @@ -826,7 +1062,7 @@ func (h *Handler) listRuns(ctx context.Context, nodeID int64) []models.CommandRu var runs []models.CommandRun for rows.Next() { var run models.CommandRun - if err := rows.Scan(&run.ID, &run.JobID, &run.NodeID, &run.Action, &run.Status, &run.Output, &run.TriggeredBy, &run.StartedAt, &run.FinishedAt); err == nil { + if err := rows.Scan(&run.ID, &run.JobID, &run.NodeID, &run.Action, &run.CommandText, &run.Status, &run.Output, &run.TriggeredBy, &run.StartedAt, &run.FinishedAt); err == nil { runs = append(runs, run) } } @@ -835,7 +1071,7 @@ func (h *Handler) listRuns(ctx context.Context, nodeID int64) []models.CommandRu func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, error) { return h.nodesDB().QueryContext(ctx, ` - SELECT id, job_id, node_id, action, status, output, triggered_by, started_at, finished_at + SELECT id, job_id, node_id, action, command_text, status, output, triggered_by, started_at, finished_at FROM command_runs WHERE node_id = ? ORDER BY started_at DESC @@ -843,6 +1079,14 @@ func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, e `, nodeID) } +func (h *Handler) settingsRuns(ctx context.Context) []models.CommandRun { + runs, err := h.nodes.ListCommandHistory(ctx, h.org.ID) + if err != nil { + return nil + } + return runs +} + func (h *Handler) nodesDB() *sql.DB { return h.repo.DB() } @@ -905,7 +1149,7 @@ func defaultIfEmpty(value, fallback string) string { func isAllowedTheme(value string) bool { switch value { - case "dark", "light", "green", "red", "blue": + case "dark", "light": return true default: return false @@ -916,6 +1160,20 @@ func isAllowedMode(value string) bool { return value == "dark" || value == "light" } +func normalizeTheme(theme, mode string) string { + if isAllowedTheme(theme) { + return theme + } + return normalizeMode(mode) +} + +func normalizeMode(mode string) string { + if isAllowedMode(mode) { + return mode + } + return "dark" +} + func saveKeyUpload(r *http.Request, field string) (string, error) { file, header, err := r.FormFile(field) if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go index 31036dd..e338cac 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -98,7 +98,9 @@ type CommandRun struct { NodeID int64 JobName string NodeName string + GroupName string Action string + CommandText string Status string Output string TriggeredBy *int64 @@ -106,3 +108,57 @@ type CommandRun struct { FinishedAt *time.Time DurationText string } + +type UptimeMonitor struct { + ID int64 + OrganizationID int64 + NodeID int64 + NodeName string + GroupName string + Name string + Target string + MonitorType string + IntervalSeconds int64 + Enabled bool + LastStatus string + LastLatencyMS int64 + LastCheckedAt *time.Time + LastError string + UpSinceAt *time.Time + CurrentOutageStartedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type UptimeCheck struct { + ID int64 + MonitorID int64 + Status string + LatencyMS int64 + ErrorMessage string + CheckedAt time.Time +} + +type UptimeIncident struct { + ID int64 + MonitorID int64 + MonitorName string + NodeName string + GroupName string + ErrorMessage string + StartedAt time.Time + EndedAt *time.Time + DurationSeconds int64 + DurationText string +} + +type UptimePeriodSummary struct { + TotalChecks int64 + UpChecks int64 + DownChecks int64 + AvgLatencyMS int64 + DowntimeSeconds int64 + IncidentCount int64 + LongestIncidentSeconds int64 + AvgIncidentSeconds int64 +} diff --git a/internal/services/node.go b/internal/services/node.go index bf587e5..72685d4 100644 --- a/internal/services/node.go +++ b/internal/services/node.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net" + "regexp" "strings" "time" @@ -172,6 +173,39 @@ func (s *NodeService) SaveNode(ctx context.Context, node *models.Node) error { return err } +func (s *NodeService) EnsureUptimeMonitorForNode(ctx context.Context, node *models.Node) error { + target := fmt.Sprintf("%s:%d", strings.TrimSpace(node.IPAddress), node.SSHPort) + name := strings.TrimSpace(node.Name) + if name == "" { + name = target + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO uptime_monitors ( + organization_id, node_id, name, target, monitor_type, interval_seconds, enabled + ) VALUES (?, ?, ?, ?, 'ssh', 60, 1) + ON CONFLICT(node_id) DO UPDATE SET + organization_id = excluded.organization_id, + name = excluded.name, + target = excluded.target, + updated_at = CURRENT_TIMESTAMP + `, node.OrganizationID, node.ID, name, target) + return err +} + +func (s *NodeService) EnsureUptimeMonitors(ctx context.Context, orgID int64) error { + nodes, err := s.ListNodes(ctx, orgID) + if err != nil { + return err + } + for i := range nodes { + if err := s.EnsureUptimeMonitorForNode(ctx, &nodes[i]); err != nil { + return err + } + } + return nil +} + func (s *NodeService) DeleteNode(ctx context.Context, orgID, nodeID int64) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -237,6 +271,13 @@ echo "UPTIME=${uptime:-0}" output, err := s.RunSSHCommand(ctx, node, strings.TrimSpace(statsScript)) if err != nil { + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "refresh-stats", + CommandText: sanitizeCommand(statsScript), + Status: "failed", + Output: strings.TrimSpace(output + "\n" + err.Error()), + }) return "", err } @@ -270,6 +311,13 @@ echo "UPTIME=${uptime:-0}" node.DiskUsage = stats["DISK"] node.UptimeSeconds = int64(stats["UPTIME"]) node.LastSeenAt = &now + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "refresh-stats", + CommandText: sanitizeCommand(statsScript), + Status: "completed", + Output: output, + }) return output, nil } @@ -304,6 +352,15 @@ func (s *NodeService) RefreshNodeInventory(ctx context.Context, node *models.Nod `DISK_GB="$(df -BG / 2>/dev/null | awk 'NR==2 {gsub(/G/, "", $2); print $2}')"; echo DISK_GB="${DISK_GB:-0}"`, }, " ; ")) if err != nil { + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "refresh-inventory", + CommandText: sanitizeCommand(strings.Join([]string{ + `read /etc/os-release, hostname, kernel, package manager, cpu, gpu, shell, package count, memory, disk`, + }, "")), + Status: "failed", + Output: strings.TrimSpace(output + "\n" + err.Error()), + }) return "", err } @@ -353,6 +410,14 @@ func (s *NodeService) RefreshNodeInventory(ctx context.Context, node *models.Nod return output, err } + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "refresh-inventory", + CommandText: "inventory probe", + Status: "completed", + Output: output, + }) + return output, nil } @@ -419,10 +484,14 @@ func (s *NodeService) RunAction(ctx context.Context, node *models.Node, action s status = "failed" } - _, _ = s.db.ExecContext(ctx, ` - INSERT INTO command_runs (node_id, action, status, output, triggered_by) - VALUES (?, ?, ?, ?, ?) - `, node.ID, action, status, output, userID) + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: action, + CommandText: sanitizeCommand(command), + Status: status, + Output: output, + TriggeredBy: userID, + }) return output, err } @@ -436,10 +505,16 @@ func (s *NodeService) RunAdHocCommand(ctx context.Context, node *models.Node, la } now := time.Now() - _, _ = s.db.ExecContext(ctx, ` - INSERT INTO command_runs (node_id, action, status, output, triggered_by, started_at, finished_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, node.ID, label, status, output, userID, now, now) + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: label, + CommandText: sanitizeCommand(command), + Status: status, + Output: output, + TriggeredBy: userID, + StartedAt: &now, + FinishedAt: &now, + }) return output, err } @@ -487,11 +562,12 @@ func (s *NodeService) CreateAutomation(ctx context.Context, job *models.Automati func (s *NodeService) ListJobRuns(ctx context.Context, orgID int64) ([]models.CommandRun, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT cr.id, cr.job_id, cr.node_id, cr.action, cr.status, cr.output, cr.triggered_by, - cr.started_at, cr.finished_at, COALESCE(j.name, ''), COALESCE(n.name, '') + SELECT cr.id, cr.job_id, cr.node_id, cr.action, cr.command_text, cr.status, cr.output, cr.triggered_by, + cr.started_at, cr.finished_at, COALESCE(j.name, ''), COALESCE(n.name, ''), COALESCE(g.name, '') FROM command_runs cr LEFT JOIN automation_jobs j ON j.id = cr.job_id LEFT JOIN nodes n ON n.id = cr.node_id + LEFT JOIN vm_groups g ON g.id = n.group_id WHERE j.organization_id = ? OR (j.id IS NULL AND n.organization_id = ?) ORDER BY cr.started_at DESC LIMIT 50 @@ -505,8 +581,8 @@ func (s *NodeService) ListJobRuns(ctx context.Context, orgID int64) ([]models.Co for rows.Next() { var run models.CommandRun if err := rows.Scan( - &run.ID, &run.JobID, &run.NodeID, &run.Action, &run.Status, &run.Output, &run.TriggeredBy, - &run.StartedAt, &run.FinishedAt, &run.JobName, &run.NodeName, + &run.ID, &run.JobID, &run.NodeID, &run.Action, &run.CommandText, &run.Status, &run.Output, &run.TriggeredBy, + &run.StartedAt, &run.FinishedAt, &run.JobName, &run.NodeName, &run.GroupName, ); err != nil { return nil, err } @@ -517,6 +593,308 @@ func (s *NodeService) ListJobRuns(ctx context.Context, orgID int64) ([]models.Co return runs, rows.Err() } +func (s *NodeService) ListCommandHistory(ctx context.Context, orgID int64) ([]models.CommandRun, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT cr.id, cr.job_id, cr.node_id, cr.action, cr.command_text, cr.status, cr.output, cr.triggered_by, + cr.started_at, cr.finished_at, COALESCE(j.name, ''), COALESCE(n.name, ''), COALESCE(g.name, '') + FROM command_runs cr + LEFT JOIN automation_jobs j ON j.id = cr.job_id + LEFT JOIN nodes n ON n.id = cr.node_id + LEFT JOIN vm_groups g ON g.id = n.group_id + WHERE n.organization_id = ? OR j.organization_id = ? + ORDER BY cr.started_at DESC + LIMIT 200 + `, orgID, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var runs []models.CommandRun + for rows.Next() { + var run models.CommandRun + if err := rows.Scan( + &run.ID, &run.JobID, &run.NodeID, &run.Action, &run.CommandText, &run.Status, &run.Output, &run.TriggeredBy, + &run.StartedAt, &run.FinishedAt, &run.JobName, &run.NodeName, &run.GroupName, + ); err != nil { + return nil, err + } + run.DurationText = formatDuration(run.StartedAt, run.FinishedAt) + runs = append(runs, run) + } + return runs, rows.Err() +} + +func (s *NodeService) ListUptimeMonitors(ctx context.Context, orgID int64) ([]models.UptimeMonitor, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT m.id, m.organization_id, m.node_id, COALESCE(n.name, ''), COALESCE(g.name, ''), + m.name, m.target, m.monitor_type, m.interval_seconds, m.enabled, m.last_status, + m.last_latency_ms, m.last_checked_at, m.last_error, m.up_since_at, m.current_outage_started_at, + m.created_at, m.updated_at + FROM uptime_monitors m + LEFT JOIN nodes n ON n.id = m.node_id + LEFT JOIN vm_groups g ON g.id = n.group_id + WHERE m.organization_id = ? + ORDER BY m.name ASC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var monitors []models.UptimeMonitor + for rows.Next() { + var monitor models.UptimeMonitor + if err := rows.Scan( + &monitor.ID, &monitor.OrganizationID, &monitor.NodeID, &monitor.NodeName, &monitor.GroupName, + &monitor.Name, &monitor.Target, &monitor.MonitorType, &monitor.IntervalSeconds, &monitor.Enabled, &monitor.LastStatus, + &monitor.LastLatencyMS, &monitor.LastCheckedAt, &monitor.LastError, &monitor.UpSinceAt, &monitor.CurrentOutageStartedAt, + &monitor.CreatedAt, &monitor.UpdatedAt, + ); err != nil { + return nil, err + } + monitors = append(monitors, monitor) + } + return monitors, rows.Err() +} + +func (s *NodeService) ListRecentUptimeChecks(ctx context.Context, orgID int64, limitPerMonitor int) (map[int64][]models.UptimeCheck, error) { + if limitPerMonitor <= 0 { + limitPerMonitor = 24 + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT c.id, c.monitor_id, c.status, c.latency_ms, c.error_message, c.checked_at + FROM uptime_checks c + INNER JOIN uptime_monitors m ON m.id = c.monitor_id + WHERE m.organization_id = ? + ORDER BY c.checked_at DESC + LIMIT 500 + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + results := map[int64][]models.UptimeCheck{} + for rows.Next() { + var check models.UptimeCheck + if err := rows.Scan(&check.ID, &check.MonitorID, &check.Status, &check.LatencyMS, &check.ErrorMessage, &check.CheckedAt); err != nil { + return nil, err + } + if len(results[check.MonitorID]) >= limitPerMonitor { + continue + } + results[check.MonitorID] = append(results[check.MonitorID], check) + } + return results, rows.Err() +} + +func (s *NodeService) ListRecentUptimeIncidents(ctx context.Context, orgID int64, limit int) ([]models.UptimeIncident, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT i.id, i.monitor_id, COALESCE(m.name, ''), COALESCE(n.name, ''), COALESCE(g.name, ''), + i.error_message, i.started_at, i.ended_at + FROM uptime_incidents i + INNER JOIN uptime_monitors m ON m.id = i.monitor_id + LEFT JOIN nodes n ON n.id = m.node_id + LEFT JOIN vm_groups g ON g.id = n.group_id + WHERE m.organization_id = ? + ORDER BY i.started_at DESC + LIMIT ? + `, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var incidents []models.UptimeIncident + for rows.Next() { + var incident models.UptimeIncident + if err := rows.Scan( + &incident.ID, &incident.MonitorID, &incident.MonitorName, &incident.NodeName, &incident.GroupName, + &incident.ErrorMessage, &incident.StartedAt, &incident.EndedAt, + ); err != nil { + return nil, err + } + incident.DurationSeconds = incidentDurationSeconds(incident.StartedAt, incident.EndedAt) + incident.DurationText = humanDuration(time.Duration(incident.DurationSeconds) * time.Second) + incidents = append(incidents, incident) + } + return incidents, rows.Err() +} + +func (s *NodeService) UptimePeriodSummary(ctx context.Context, orgID int64, since *time.Time) (models.UptimePeriodSummary, error) { + var summary models.UptimePeriodSummary + args := []any{orgID} + filter := "" + if since != nil { + filter = " AND c.checked_at >= ?" + args = append(args, *since) + } + + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN c.status = 'up' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN 1 ELSE 0 END), 0), + COALESCE(CAST(AVG(CASE WHEN c.status = 'up' THEN c.latency_ms END) AS INTEGER), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN m.interval_seconds ELSE 0 END), 0) + FROM uptime_checks c + INNER JOIN uptime_monitors m ON m.id = c.monitor_id + WHERE m.organization_id = ?`+filter, args...).Scan( + &summary.TotalChecks, &summary.UpChecks, &summary.DownChecks, &summary.AvgLatencyMS, &summary.DowntimeSeconds, + ); err != nil { + return summary, err + } + + incidentArgs := []any{orgID} + incidentFilter := "" + if since != nil { + incidentFilter = " AND i.started_at >= ?" + incidentArgs = append(incidentArgs, *since) + } + + var longest sql.NullInt64 + var avg sql.NullFloat64 + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + MAX(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)), + AVG(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)) + FROM uptime_incidents i + INNER JOIN uptime_monitors m ON m.id = i.monitor_id + WHERE m.organization_id = ?`+incidentFilter, incidentArgs...).Scan( + &summary.IncidentCount, &longest, &avg, + ); err != nil { + return summary, err + } + if longest.Valid { + summary.LongestIncidentSeconds = longest.Int64 + } + if avg.Valid { + summary.AvgIncidentSeconds = int64(avg.Float64) + } + return summary, nil +} + +func (s *NodeService) RunAllUptimeChecks(ctx context.Context, orgID int64) error { + monitors, err := s.ListUptimeMonitors(ctx, orgID) + if err != nil { + return err + } + for i := range monitors { + if !monitors[i].Enabled { + continue + } + _ = s.RunUptimeCheck(ctx, &monitors[i]) + } + return nil +} + +func (s *NodeService) RunUptimeCheck(ctx context.Context, monitor *models.UptimeMonitor) error { + target := strings.TrimSpace(monitor.Target) + if target == "" { + return fmt.Errorf("empty monitor target") + } + + startedAt := time.Now() + timeout := 5 * time.Second + conn, err := net.DialTimeout("tcp", target, timeout) + latencyMS := int64(time.Since(startedAt).Milliseconds()) + status := "up" + errorMessage := "" + if err != nil { + status = "down" + errorMessage = err.Error() + } else { + _ = conn.Close() + } + + if latencyMS < 0 { + latencyMS = 0 + } + if _, execErr := s.db.ExecContext(ctx, ` + INSERT INTO uptime_checks (monitor_id, status, latency_ms, error_message, checked_at) + VALUES (?, ?, ?, ?, ?) + `, monitor.ID, status, latencyMS, errorMessage, startedAt); execErr != nil { + return execErr + } + + if status == "down" && monitor.LastStatus != "down" { + if _, execErr := s.db.ExecContext(ctx, ` + INSERT INTO uptime_incidents (monitor_id, error_message, started_at) + VALUES (?, ?, ?) + `, monitor.ID, errorMessage, startedAt); execErr != nil { + return execErr + } + } + + if status == "up" && monitor.LastStatus == "down" { + if _, execErr := s.db.ExecContext(ctx, ` + UPDATE uptime_incidents + SET ended_at = ? + WHERE id = ( + SELECT id + FROM uptime_incidents + WHERE monitor_id = ? AND ended_at IS NULL + ORDER BY started_at DESC + LIMIT 1 + ) + `, startedAt, monitor.ID); execErr != nil { + return execErr + } + } + + var upSinceAt any + var outageStartedAt any + if status == "up" { + if monitor.LastStatus == "up" && monitor.UpSinceAt != nil { + upSinceAt = *monitor.UpSinceAt + } else { + upSinceAt = startedAt + } + outageStartedAt = nil + } else { + if monitor.LastStatus == "down" && monitor.CurrentOutageStartedAt != nil { + outageStartedAt = *monitor.CurrentOutageStartedAt + } else { + outageStartedAt = startedAt + } + upSinceAt = nil + } + + _, err = s.db.ExecContext(ctx, ` + UPDATE uptime_monitors + SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, + up_since_at = ?, current_outage_started_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, status, latencyMS, startedAt, errorMessage, upSinceAt, outageStartedAt, monitor.ID) + if err != nil { + return err + } + + monitor.LastStatus = status + monitor.LastLatencyMS = latencyMS + monitor.LastCheckedAt = &startedAt + monitor.LastError = errorMessage + if status == "up" { + if upTime, ok := upSinceAt.(time.Time); ok { + monitor.UpSinceAt = &upTime + } + monitor.CurrentOutageStartedAt = nil + } else { + if downTime, ok := outageStartedAt.(time.Time); ok { + monitor.CurrentOutageStartedAt = &downTime + } + monitor.UpSinceAt = nil + } + + return nil +} + func sendMagicPacket(macAddress string) error { hw, err := net.ParseMAC(macAddress) if err != nil { @@ -556,6 +934,10 @@ func NewSchedulerService(database *sql.DB, nodeService *NodeService) *SchedulerS } func (s *SchedulerService) Start(ctx context.Context, orgID int64, refreshSpec string) error { + if err := s.nodeService.EnsureUptimeMonitors(ctx, orgID); err != nil { + return err + } + if _, err := s.cron.AddFunc(refreshSpec, func() { nodes, err := s.nodeService.ListNodes(ctx, orgID) if err != nil { @@ -571,6 +953,13 @@ func (s *SchedulerService) Start(ctx context.Context, orgID int64, refreshSpec s return err } + if _, err := s.cron.AddFunc("@every 1m", func() { + _ = s.nodeService.EnsureUptimeMonitors(context.Background(), orgID) + _ = s.nodeService.RunAllUptimeChecks(context.Background(), orgID) + }); err != nil { + return err + } + jobs, err := s.nodeService.ListAutomations(ctx, orgID) if err != nil { return err @@ -611,10 +1000,16 @@ func (s *SchedulerService) runAutomation(ctx context.Context, job models.Automat } finishedAt := time.Now() - _, _ = s.db.ExecContext(ctx, ` - INSERT INTO command_runs (job_id, node_id, action, status, output, started_at, finished_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, job.ID, node.ID, job.Name, status, output, startedAt, finishedAt) + s.nodeService.logCommandRun(ctx, commandRunParams{ + JobID: &job.ID, + NodeID: node.ID, + Action: job.Name, + CommandText: sanitizeCommand(job.Command), + Status: status, + Output: output, + StartedAt: &startedAt, + FinishedAt: &finishedAt, + }) lastRunAt = finishedAt } @@ -673,3 +1068,95 @@ func formatDuration(startedAt time.Time, finishedAt *time.Time) string { } return fmt.Sprintf("%dm %ds", minutes, seconds) } + +type commandRunParams struct { + JobID *int64 + NodeID int64 + Action string + CommandText string + Status string + Output string + TriggeredBy *int64 + StartedAt *time.Time + FinishedAt *time.Time +} + +func (s *NodeService) logCommandRun(ctx context.Context, params commandRunParams) { + startedAt := time.Now() + if params.StartedAt != nil { + startedAt = *params.StartedAt + } + finishedAt := params.FinishedAt + if finishedAt == nil { + value := time.Now() + finishedAt = &value + } + + _, _ = s.db.ExecContext(ctx, ` + INSERT INTO command_runs (job_id, node_id, action, command_text, status, output, triggered_by, started_at, finished_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, params.JobID, params.NodeID, params.Action, params.CommandText, params.Status, params.Output, params.TriggeredBy, startedAt, finishedAt) +} + +func sanitizeCommand(command string) string { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return "" + } + + patterns := []*regexp.Regexp{ + regexp.MustCompile(`(?i)(--password(?:=|\s+))(\S+)`), + regexp.MustCompile(`(?i)(--token(?:=|\s+))(\S+)`), + regexp.MustCompile(`(?i)(--secret(?:=|\s+))(\S+)`), + regexp.MustCompile(`(?i)\b(password|passwd|token|secret|api[_-]?key)\s*=\s*(['"]?)[^'"\s]+(['"]?)`), + } + + sanitized := trimmed + for _, pattern := range patterns { + sanitized = pattern.ReplaceAllString(sanitized, `$1[REDACTED]`) + } + return sanitized +} + +func incidentDurationSeconds(startedAt time.Time, endedAt *time.Time) int64 { + end := time.Now() + if endedAt != nil { + end = *endedAt + } + if end.Before(startedAt) { + return 0 + } + return int64(end.Sub(startedAt).Seconds()) +} + +func humanDuration(duration time.Duration) string { + if duration < 0 { + duration = 0 + } + duration = duration.Round(time.Second) + days := int(duration / (24 * time.Hour)) + duration -= time.Duration(days) * 24 * time.Hour + hours := int(duration / time.Hour) + duration -= time.Duration(hours) * time.Hour + minutes := int(duration / time.Minute) + duration -= time.Duration(minutes) * time.Minute + seconds := int(duration / time.Second) + + parts := make([]string, 0, 4) + if days > 0 { + parts = append(parts, fmt.Sprintf("%dd", days)) + } + if hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + } + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%dm", minutes)) + } + if seconds > 0 || len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%ds", seconds)) + } + if len(parts) > 2 { + parts = parts[:2] + } + return strings.Join(parts, " ") +} diff --git a/internal/views/pages/settings.gohtml b/internal/views/pages/settings.gohtml index 2e1f129..5105e44 100644 --- a/internal/views/pages/settings.gohtml +++ b/internal/views/pages/settings.gohtml @@ -4,7 +4,7 @@
Themes

Appearance

-

Five defaults. Colored themes work in dark or light.

+

Dark or light only.

@@ -14,18 +14,9 @@

Theme Presets

-
- -
- - - - -
-
- +
-
+
-
+
-
- - -
-
- - -
-
- - -
@@ -90,4 +57,40 @@
+ +
+
+
+

Command History

+
+ + + + + + + + + + + {{if $data.Runs}} + {{range $data.Runs}} + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
Group/VMidTimeStatusCommand
{{if .GroupName}}{{.GroupName}} / {{end}}{{.NodeName}}{{.StartedAt.Format "2006-01-02 15:04:05"}}{{.Status}}{{if .CommandText}}{{.CommandText}}{{else}}{{.Action}}{{end}}
No command history yet.
+
+
+
+
{{end}} diff --git a/internal/views/pages/uptime.gohtml b/internal/views/pages/uptime.gohtml new file mode 100644 index 0000000..0659360 --- /dev/null +++ b/internal/views/pages/uptime.gohtml @@ -0,0 +1,182 @@ +{{define "content"}} +{{$data := .Content}} +
+
+
+
+
Monitors
+
{{$data.Summary.TotalMonitors}}
+
+
+
+
+
+
+
Up
+
{{$data.Summary.UpMonitors}}
+
+
+
+
+
+
+
Down
+
{{$data.Summary.DownMonitors}}
+
+
+
+
+
+
+
Avg latency
+
{{if $data.Summary.AvgLatencyMS}}{{$data.Summary.AvgLatencyMS}}ms{{else}}-{{end}}
+
+
+
+
+ +
+
+
+
+
+

Monitors

+ SSH endpoint checks +
+
+ {{if $data.Monitors}} + {{range $data.Monitors}} +
+
+
+
+
{{.Monitor.Name}}
+
{{.Monitor.Target}}{{if .Monitor.GroupName}} · {{.Monitor.GroupName}}{{end}}
+
+ + {{if eq .Monitor.LastStatus "down"}}Down{{else if eq .Monitor.LastStatus "up"}}Up{{else}}Pending{{end}} + +
+ +
+
+ Availability + {{.AvailabilityText}} +
+
+ Latency + {{if .Monitor.LastLatencyMS}}{{.Monitor.LastLatencyMS}}ms{{else}}-{{end}} +
+
+ Checked + {{.LastCheckedText}} +
+
+ Interval + {{.IntervalText}} +
+
+ + + +
+ {{.StateDurationText}} + {{if .Monitor.LastError}}{{.Monitor.LastError}}{{else}}{{.Monitor.NodeName}}{{end}} +
+
+
+ {{end}} + {{else}} +
+
No monitors yet. Add a VM to start tracking uptime.
+
+ {{end}} +
+
+
+
+ +
+
+
+

Availability

+
+ + + + + + + + + + + + + {{range $data.Periods}} + + + + + + + + + {{end}} + +
PeriodAvailabilityDowntimeIncidentsLongestAvg
{{.Label}}{{.AvailabilityText}}{{.DowntimeText}}{{.Incidents}}{{.LongestText}}{{.AverageText}}
+
+
+
+
+
+ +
+
+
+
+

Incidents

+ {{len $data.Incidents}} recent +
+
+ + + + + + + + + + + + {{if $data.Incidents}} + {{range $data.Incidents}} + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
MonitorStartedEndedDurationError
{{.MonitorName}}{{.StartedAt.Format "2006-01-02 15:04:05"}}{{if .EndedAt}}{{.EndedAt.Format "2006-01-02 15:04:05"}}{{else}}Active{{end}}{{.DurationText}}{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}Connection failed{{end}}
No incidents recorded yet.
+
+
+
+
+{{end}} From 2d46a9289c5b771445b32d112b139df89a17977e Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 18:08:10 -0500 Subject: [PATCH 02/13] feat(ui): refresh shell, auth, and node visuals --- internal/views/layouts/app.gohtml | 34 +- internal/views/layouts/auth.gohtml | 17 +- internal/views/layouts/base.gohtml | 3 +- internal/views/pages/dashboard.gohtml | 4 +- internal/views/pages/login.gohtml | 14 +- internal/views/pages/node.gohtml | 22 +- internal/views/pages/register.gohtml | 16 +- internal/views/views.go | 34 +- web/static/css/app.css | 451 +++++++++++++++++++++++--- web/static/js/app.js | 23 ++ 10 files changed, 497 insertions(+), 121 deletions(-) diff --git a/internal/views/layouts/app.gohtml b/internal/views/layouts/app.gohtml index 372f586..0d724fa 100644 --- a/internal/views/layouts/app.gohtml +++ b/internal/views/layouts/app.gohtml @@ -16,6 +16,12 @@ + {{else if eq .CurrentPath "/uptime"}} + + + {{else if eq .CurrentPath "/groups"}} + -

Create account

+ {{end}} diff --git a/internal/views/pages/node.gohtml b/internal/views/pages/node.gohtml index a1f82a7..50db0e6 100644 --- a/internal/views/pages/node.gohtml +++ b/internal/views/pages/node.gohtml @@ -17,11 +17,21 @@
-
+
@@ -60,8 +70,8 @@
-
- +
+
Distribution
@@ -69,7 +79,7 @@
-
+
diff --git a/internal/views/pages/register.gohtml b/internal/views/pages/register.gohtml index b775686..5ee649d 100644 --- a/internal/views/pages/register.gohtml +++ b/internal/views/pages/register.gohtml @@ -1,22 +1,24 @@ {{define "content"}} -
-

Create Account

+
+

Create account

{{with .Content}}{{with .Error}}
{{.}}
{{end}}{{end}}
- +
- +
- +
- +
-

Already registered? Sign in

+ {{end}} diff --git a/internal/views/views.go b/internal/views/views.go index 46313e5..be8a2fd 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -38,7 +38,6 @@ func NewRenderer() (*Renderer, error) { "contains": strings.Contains, "distroIconClass": distroIconClass, "nodeIconClass": nodeIconClass, - "nodeIconName": nodeIconName, "nodeIconPending": nodeIconPending, "packageManagerIconClass": packageManagerIconClass, "packageManagerLabel": packageManagerLabel, @@ -134,11 +133,11 @@ func distroIconClass(distro string) string { value := strings.ToLower(strings.TrimSpace(distro)) switch { case strings.Contains(value, "ubuntu"): - return "fa-brands fa-ubuntu" + return "fl-ubuntu" case strings.Contains(value, "debian"): - return "fa-brands fa-debian" + return "fl-debian" case strings.Contains(value, "arch"), strings.Contains(value, "manjaro"), strings.Contains(value, "endeavouros"): - return "ti ti-brand-archlinux" + return "fl-archlinux" default: return "ti ti-server-2" } @@ -151,35 +150,14 @@ func nodeIconClass(distro, packageManager string) string { switch strings.ToLower(strings.TrimSpace(packageManager)) { case "apt": - return "fa-brands fa-debian" + return "fl-debian" case "pacman": - return "ti ti-brand-archlinux" + return "fl-archlinux" default: return "ti ti-server-2" } } -func nodeIconName(distro, packageManager string) string { - value := strings.ToLower(strings.TrimSpace(distro)) - switch { - case strings.Contains(value, "ubuntu"): - return "ubuntu" - case strings.Contains(value, "debian"): - return "debian" - case strings.Contains(value, "arch"), strings.Contains(value, "manjaro"), strings.Contains(value, "endeavouros"): - return "arch" - } - - switch strings.ToLower(strings.TrimSpace(packageManager)) { - case "apt": - return "debian" - case "pacman": - return "arch" - default: - return "server" - } -} - func nodeIconPending(distro, packageManager string) bool { distroValue := strings.ToLower(strings.TrimSpace(distro)) packageValue := strings.ToLower(strings.TrimSpace(packageManager)) @@ -189,7 +167,7 @@ func nodeIconPending(distro, packageManager string) bool { func packageManagerIconClass(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "apt": - return "fa-brands fa-debian" + return "ti ti-brand-debian" case "pacman": return "ti ti-brand-archlinux" case "dnf", "yum", "zypper", "apk", "nix", "emerge": diff --git a/web/static/css/app.css b/web/static/css/app.css index 8243933..3dbdf3c 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -24,8 +24,7 @@ } body.theme-dark, -body.theme-light, -body.theme-blue { +body.theme-light { --color-primary-50: 239 246 255; --color-primary-100: 219 234 254; --color-primary-200: 191 219 254; @@ -39,37 +38,6 @@ body.theme-blue { --color-primary-950: 23 37 84; } -body.theme-green { - --color-primary-50: 236 253 245; - --color-primary-100: 209 250 229; - --color-primary-200: 167 243 208; - --color-primary-300: 110 231 183; - --color-primary-400: 52 211 153; - --color-primary-500: 16 185 129; - --color-primary-600: 5 150 105; - --color-primary-700: 4 120 87; - --color-primary-800: 6 95 70; - --color-primary-900: 6 78 59; - --color-primary-950: 2 44 34; -} - -body.theme-red { - --color-primary-50: 254 242 242; - --color-primary-100: 254 226 226; - --color-primary-200: 254 202 202; - --color-primary-300: 252 165 165; - --color-primary-400: 248 113 113; - --color-primary-500: 239 68 68; - --color-primary-600: 220 38 38; - --color-primary-700: 185 28 28; - --color-primary-800: 153 27 27; - --color-primary-900: 127 29 29; - --color-primary-950: 69 10 10; -} - -body.theme-blue, -body.theme-green, -body.theme-red, body.theme-dark, body.theme-light { --bs-primary: rgb(var(--color-primary-600)); @@ -133,12 +101,47 @@ a { .auth-card { background: color-mix(in srgb, var(--ma-surface-overlay) 92%, transparent); backdrop-filter: blur(14px); + border: 1px solid var(--ma-border); } body[data-bs-theme="light"] .auth-card { background: color-mix(in srgb, white 58%, var(--ma-surface-base)); } +.auth-shell { + background: + radial-gradient(circle at top center, rgba(var(--color-primary-500), 0.16), transparent 24%), + linear-gradient(180deg, #181c22 0%, #14181e 100%); +} + +body[data-bs-theme="light"] .auth-shell { + background: + radial-gradient(circle at top center, rgba(var(--color-primary-300), 0.28), transparent 22%), + linear-gradient(180deg, #f4f7fb 0%, #eceff4 100%); +} + +.container-tight { + max-width: 26rem; +} + +.auth-logo { + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: 1rem; + box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.22); +} + +.auth-card-tabler { + border-radius: 1rem; + box-shadow: 0 1.5rem 3.5rem rgba(15, 23, 42, 0.28); +} + +.auth-input { + min-height: 2.75rem; + border-radius: 0.85rem; +} + .auth-brand { background: radial-gradient(circle at top, rgba(var(--color-primary-600), 0.32), transparent 35%), @@ -199,6 +202,28 @@ body[data-bs-theme="light"] .auth-feature { font-size: 1.4rem; } +.chip-icon-plain { + background: transparent; + box-shadow: none; + color: var(--bs-emphasis-color); +} + +.distro-icon { + color: var(--bs-emphasis-color); +} + +.distro-icon.fl-ubuntu { + color: #e95420; +} + +.distro-icon.fl-debian { + color: #d70a53; +} + +.distro-icon.fl-archlinux { + color: #1793d1; +} + .content { min-width: 0; min-height: 0; @@ -228,12 +253,6 @@ body[data-bs-theme="light"] .app-sidebar { background: color-mix(in srgb, white 22%, var(--ma-surface-sidebar)); } -.app-sidebar-nav .btn { - justify-content: flex-start; - align-items: center; - gap: 0.4rem; -} - .sidebar-logo { width: 3rem; height: 3rem; @@ -242,6 +261,75 @@ body[data-bs-theme="light"] .app-sidebar { box-shadow: 0 0.75rem 2rem rgba(var(--color-primary-700), 0.18); } +.app-sidebar-inner { + gap: 0.9rem; +} + +.sidebar-brand { + padding: 0.35rem 0.2rem 0.55rem; +} + +.sidebar-brand-title { + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.sidebar-section-label { + padding: 0 0.65rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.app-sidebar-nav { + gap: 0.35rem; +} + +.app-nav-link { + display: flex; + align-items: center; + gap: 0.8rem; + min-height: 2.9rem; + padding: 0.75rem 0.85rem; + border-radius: 0.95rem; + color: var(--bs-secondary-color); + font-weight: 600; + transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.app-nav-link i { + width: 1.1rem; + font-size: 1.05rem; + text-align: center; +} + +.app-nav-link:hover { + color: var(--bs-emphasis-color); + background: color-mix(in srgb, var(--ma-surface-2) 92%, transparent); + transform: translateX(2px); +} + +.app-nav-link.is-active { + color: #fff; + background: linear-gradient(135deg, rgba(var(--color-primary-600), 0.96), rgba(var(--color-primary-700), 0.96)); + box-shadow: 0 0.9rem 2rem rgba(var(--color-primary-900), 0.2); +} + +body[data-bs-theme="light"] .app-nav-link.is-active { + color: #fff; +} + +.app-header { + box-shadow: inset 0 -1px 0 var(--ma-border); +} + +.app-footer { + box-shadow: inset 0 1px 0 var(--ma-border); +} + .min-w-0 { min-width: 0; } @@ -285,6 +373,7 @@ body[data-bs-theme="light"] .app-sidebar { align-items: center; justify-content: center; padding: 0; + border-radius: 0.85rem; } .node-chip { @@ -311,6 +400,90 @@ body[data-bs-theme="light"] .app-sidebar { position: relative; } +.kpi-card-featured .card-body { + min-height: 12rem; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.kpi-card-label { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.kpi-card-subtle { + margin-top: 0.35rem; + font-size: 0.9rem; + color: var(--bs-secondary-color); +} + +.kpi-card-chip { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--ma-border); + background: color-mix(in srgb, var(--ma-surface-base) 78%, var(--ma-surface-2)); + color: var(--bs-secondary-color); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} + +.kpi-card-featured-value { + position: relative; + z-index: 1; + font-size: clamp(2.3rem, 4vw, 3.15rem); + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + color: var(--bs-emphasis-color); +} + +.kpi-card-featured-trend { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; +} + +.kpi-card-trend { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.3rem 0.65rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; +} + +.kpi-card-trend.is-up { + color: #86efac; + background: rgba(20, 83, 45, 0.58); +} + +.kpi-card-trend.is-down { + color: #fca5a5; + background: rgba(127, 29, 29, 0.58); +} + +.kpi-card-trend.is-flat { + color: #cbd5e1; + background: rgba(51, 65, 85, 0.6); +} + +.kpi-card-trend-copy { + font-size: 0.88rem; + color: var(--bs-secondary-color); +} + .kpi-graph { position: absolute; inset: 0; @@ -323,6 +496,13 @@ body[data-bs-theme="light"] .app-sidebar { height: 100%; } +.kpi-card-featured .kpi-graph { + inset: auto 0 0 0; + height: 68%; + opacity: 0.92; + mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.45) 26%, rgba(0, 0, 0, 1) 100%); +} + .node-tile { display: grid; grid-template-columns: 64px minmax(0, 1fr); @@ -382,6 +562,12 @@ body[data-bs-theme="light"] .app-sidebar { font-size: 1rem; } +.node-brand-icon { + width: 3rem; + height: 3rem; + font-size: 2.1rem; +} + .node-loading-icon { animation: node-icon-spin 1s linear infinite; } @@ -455,6 +641,16 @@ body[data-bs-theme="light"] .app-sidebar { font-size: 1.2rem; } +.system-summary-icon-plain { + width: 3.5rem; + height: 3.5rem; + font-size: 2.25rem; +} + +.distro-icon-lg { + font-size: 2.4rem; +} + .system-summary-label { font-size: 0.72rem; font-weight: 700; @@ -515,6 +711,23 @@ body[data-bs-theme="light"] .app-sidebar { background: var(--ma-surface-1); } +.form-control, +.form-select, +.btn, +.modal-content { + border-radius: 0.9rem; +} + +.form-control, +.form-select { + background: color-mix(in srgb, var(--ma-surface-base) 80%, var(--ma-surface-2)); +} + +body[data-bs-theme="light"] .form-control, +body[data-bs-theme="light"] .form-select { + background: color-mix(in srgb, white 72%, var(--ma-surface-1)); +} + .stat-card, .node-chip, .dashboard-loader > .card, @@ -739,23 +952,159 @@ body[data-bs-theme="light"] .theme-card { background: linear-gradient(135deg, #ffffff, #e2e8f0); } -.swatch-green { - background: linear-gradient(135deg, rgb(16 185 129), rgb(4 120 87)); -} - -.swatch-red { - background: linear-gradient(135deg, rgb(239 68 68), rgb(185 28 28)); -} - -.swatch-blue { - background: linear-gradient(135deg, rgb(59 130 246), rgb(29 78 216)); -} - .btn-check:checked + .theme-card { border-color: rgb(var(--color-primary-500)); box-shadow: 0 0 0 0.2rem rgba(var(--color-primary-500), 0.2); } +.uptime-summary-card { + background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 92%, transparent), var(--ma-surface-1)); +} + +.uptime-summary-label { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); + margin-bottom: 0.6rem; +} + +.uptime-summary-value { + font-size: clamp(1.8rem, 3vw, 2.5rem); + font-weight: 800; + line-height: 1; + color: var(--bs-emphasis-color); +} + +.uptime-monitor-card { + display: grid; + gap: 1rem; + height: 100%; + padding: 1.1rem; + border: 1px solid var(--ma-border); + border-radius: 1.15rem; + background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 90%, transparent), var(--ma-surface-1)); +} + +.uptime-monitor-card.is-up { + box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.12); +} + +.uptime-monitor-card.is-down { + box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.14); +} + +.uptime-monitor-head, +.uptime-monitor-foot, +.uptime-monitor-stat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.uptime-monitor-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5rem; + min-height: 2rem; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.uptime-monitor-badge.is-up { + color: #86efac; + background: rgba(20, 83, 45, 0.55); +} + +.uptime-monitor-badge.is-down { + color: #fca5a5; + background: rgba(127, 29, 29, 0.55); +} + +.uptime-monitor-badge.is-pending { + color: #cbd5e1; + background: rgba(51, 65, 85, 0.65); +} + +.uptime-monitor-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem 1rem; +} + +.uptime-monitor-stat { + padding: 0.75rem 0.85rem; + border-radius: 0.9rem; + background: color-mix(in srgb, var(--ma-surface-base) 74%, var(--ma-surface-2)); + font-size: 0.86rem; +} + +.uptime-monitor-stat span, +.uptime-monitor-foot { + color: var(--bs-secondary-color); +} + +.uptime-monitor-stat strong { + color: var(--bs-emphasis-color); + font-weight: 700; +} + +.uptime-check-strip { + display: flex; + align-items: flex-end; + gap: 0.28rem; + min-height: 2.25rem; + padding: 0.2rem 0; +} + +.uptime-check-pill { + flex: 1 1 0; + min-width: 0; + height: 2rem; + border-radius: 999px; + background: rgba(100, 116, 139, 0.35); +} + +.uptime-check-pill.is-up { + background: linear-gradient(180deg, rgba(34, 197, 94, 0.95), rgba(22, 163, 74, 0.45)); +} + +.uptime-check-pill.is-down { + background: linear-gradient(180deg, rgba(248, 113, 113, 0.95), rgba(185, 28, 28, 0.5)); +} + +.uptime-check-pill.is-pending { + background: rgba(100, 116, 139, 0.35); +} + +.uptime-monitor-foot { + font-size: 0.82rem; +} + +.uptime-table th { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +body[data-bs-theme="light"] .uptime-monitor-card, +body[data-bs-theme="light"] .uptime-summary-card { + background: linear-gradient(180deg, color-mix(in srgb, white 76%, var(--ma-surface-1)), var(--ma-surface-1)); +} + +body[data-bs-theme="light"] .uptime-monitor-stat { + background: color-mix(in srgb, white 82%, var(--ma-surface-1)); +} + @media (max-width: 991.98px) { .app-shell { flex-direction: column; diff --git a/web/static/js/app.js b/web/static/js/app.js index 0ca5ee6..dbcbb74 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -8,6 +8,8 @@ document.addEventListener("DOMContentLoaded", () => { const dashboardNodes = document.getElementById("dashboard-nodes"); const dashboardSearch = document.getElementById("dashboard-search"); const authToggle = document.querySelector("[data-auth-toggle]"); + const themeRadios = document.querySelectorAll('input[name="theme"]'); + const themeModeInput = document.querySelector('input[name="mode"]'); const attachXtermConsole = (consoleOutput) => { const wsPath = consoleOutput.dataset.ws; @@ -143,6 +145,7 @@ document.addEventListener("DOMContentLoaded", () => { disk: [], uptime: [], }; + let previousCpu = null; const uptimeLabel = (seconds) => { const days = Math.floor(seconds / 86400); @@ -200,11 +203,21 @@ document.addEventListener("DOMContentLoaded", () => { const ramValue = nodeLive.querySelector('[data-kpi-value="ram"]'); const diskValue = nodeLive.querySelector('[data-kpi-value="disk"]'); const uptimeValue = nodeLive.querySelector('[data-kpi-value="uptime"]'); + const cpuTrend = nodeLive.querySelector('[data-kpi-trend="cpu"]'); if (cpuValue) cpuValue.textContent = `${cpu.toFixed(1)}%`; if (ramValue) ramValue.textContent = `${ram.toFixed(1)}%`; if (diskValue) diskValue.textContent = `${disk.toFixed(1)}%`; if (uptimeValue) uptimeValue.textContent = uptimeLabel(uptime); + if (cpuTrend) { + const delta = previousCpu === null ? 0 : cpu - previousCpu; + const trendClass = delta > 0.15 ? "is-up" : delta < -0.15 ? "is-down" : "is-flat"; + const trendLabel = `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`; + cpuTrend.classList.remove("is-up", "is-down", "is-flat"); + cpuTrend.classList.add(trendClass); + cpuTrend.textContent = trendLabel; + } + previousCpu = cpu; pushValue("cpu", cpu); pushValue("ram", ram); @@ -294,6 +307,16 @@ document.addEventListener("DOMContentLoaded", () => { syncAuthMode(); } + if (themeModeInput instanceof HTMLInputElement) { + themeRadios.forEach((radio) => { + radio.addEventListener("change", () => { + if (radio instanceof HTMLInputElement && radio.checked) { + themeModeInput.value = radio.value; + } + }); + }); + } + if (typeof bootstrap !== "undefined") { document.querySelectorAll('[data-bs-toggle-tooltip="tooltip"]').forEach((element) => { new bootstrap.Tooltip(element); From 64beeba8bedffd753c79d0a4f252cc95cf01e350 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 18:42:14 -0500 Subject: [PATCH 03/13] feat(ui): add user settings and refine dashboard experience --- internal/app/app.go | 4 + internal/db/db.go | 2 + internal/handlers/handlers.go | 364 ++++++++++++++++++++-- internal/models/models.go | 1 + internal/services/node.go | 2 + internal/services/repository.go | 29 +- internal/views/layouts/app.gohtml | 47 +-- internal/views/layouts/base.gohtml | 3 + internal/views/pages/automations.gohtml | 148 ++++----- internal/views/pages/console.gohtml | 6 +- internal/views/pages/groups.gohtml | 22 +- internal/views/pages/login.gohtml | 10 +- internal/views/pages/login_otp.gohtml | 4 +- internal/views/pages/node.gohtml | 53 ++-- internal/views/pages/register.gohtml | 12 +- internal/views/pages/settings.gohtml | 143 +++++---- internal/views/pages/setup_otp.gohtml | 32 +- internal/views/pages/uptime.gohtml | 174 ++++++----- internal/views/pages/user_settings.gohtml | 192 ++++++++++++ web/static/css/app.css | 247 +++++++++++---- web/static/img/favicon-rounded.png | Bin 0 -> 38663 bytes web/static/js/app.js | 169 ++++++++-- 22 files changed, 1255 insertions(+), 409 deletions(-) create mode 100644 internal/views/pages/user_settings.gohtml create mode 100644 web/static/img/favicon-rounded.png diff --git a/internal/app/app.go b/internal/app/app.go index f76f767..fdfc6c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,7 +98,11 @@ func New() (*App, error) { protected.Get("/automations", handler.AutomationsPage) protected.Get("/uptime", handler.UptimePage) protected.Get("/settings", handler.SettingsPage) + protected.Get("/settings/user", handler.UserSettingsPage) protected.Post("/settings/theme", handler.UpdateTheme) + protected.Post("/settings/user/theme", handler.UpdateUserTheme) + protected.Post("/settings/user/password", handler.UpdateUserPassword) + protected.Post("/settings/user/2fa/{action}", handler.UpdateUserOTP) protected.Group(func(editor chi.Router) { editor.Use(localmiddleware.RequireRole(models.RoleEditor)) diff --git a/internal/db/db.go b/internal/db/db.go index cb89734..1e650a4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -57,6 +57,7 @@ func migrate(ctx context.Context, database *sql.DB) error { role TEXT NOT NULL, otp_secret TEXT NOT NULL, otp_enabled BOOLEAN NOT NULL DEFAULT 0, + theme_mode TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_id) REFERENCES organizations(id) );`, @@ -185,6 +186,7 @@ func migrate(ctx context.Context, database *sql.DB) error { alterStatements := []string{ `ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`, + `ALTER TABLE users ADD COLUMN theme_mode TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 05cb05e..d20c8aa 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -54,10 +54,28 @@ type nodePageData struct { } type settingsData struct { - ThemeVariables template.CSS - CurrentTheme string - CurrentMode string - Runs []models.CommandRun + NodeCount int + GroupCount int + JobCount int + UserCount int + Logs []settingsCommandLog +} + +type settingsCommandLog struct { + Target string + StartedAt time.Time + Status string + CommandText string + Output string +} + +type userSettingsData struct { + CurrentMode string + OTPEnabled bool + OTPSecret string + OTPQRCode string + Message string + Error string } type jobsPageData struct { @@ -70,6 +88,7 @@ type jobsPageData struct { type uptimePageData struct { Summary uptimeSummary + Chart uptimeChartData Periods []uptimePeriodRow Monitors []uptimeMonitorCard Incidents []models.UptimeIncident @@ -101,6 +120,26 @@ type uptimeMonitorCard struct { RecentChecks []models.UptimeCheck } +type uptimeChartPoint struct { + Label string + Availability float64 + AvailabilityText string + UpCount int + DownCount int + TotalCount int + AvailabilityHeight int + X float64 + Y float64 + ShowLabel bool +} + +type uptimeChartData struct { + Points []uptimeChartPoint + LabelsJSON template.JS + ValuesJSON template.JS + PointColorsJSON template.JS +} + func New(repo *services.Repository, auth *services.AuthService, sessions *services.SessionService, nodes *services.NodeService, renderer *views.Renderer, org models.Organization, baseURL string) *Handler { return &Handler{ repo: repo, @@ -748,17 +787,43 @@ func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) { func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) - org := h.currentOrganization(r.Context()) - currentMode := normalizeMode(org.ThemeMode) - currentTheme := normalizeTheme(org.Theme, currentMode) - h.render(w, r, "settings", "Theme System", settingsData{ - ThemeVariables: template.CSS(themePreview), - CurrentTheme: currentTheme, - CurrentMode: currentMode, - Runs: h.settingsRuns(r.Context()), + nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID) + groups, _ := h.repo.ListGroups(r.Context(), h.org.ID) + jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID) + userCount, _ := h.repo.CountUsers(r.Context()) + h.render(w, r, "settings", "Settings", settingsData{ + NodeCount: len(nodes), + GroupCount: len(groups), + JobCount: len(jobs), + UserCount: userCount, + Logs: h.settingsLogs(r.Context()), }, user) } +func (h *Handler) UserSettingsPage(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + data := userSettingsData{ + CurrentMode: effectiveThemeMode(user, h.currentOrganization(r.Context())), + OTPEnabled: user.OTPEnabled, + Message: strings.TrimSpace(r.URL.Query().Get("message")), + Error: strings.TrimSpace(r.URL.Query().Get("error")), + } + + if !user.OTPEnabled { + uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret) + png, _ := qrcode.Encode(uri, qrcode.Medium, 256) + data.OTPSecret = user.OTPSecret + data.OTPQRCode = "data:image/png;base64," + encodeBase64(png) + } + + h.render(w, r, "user_settings", "User Settings", data, user) +} + func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user) @@ -800,6 +865,122 @@ func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings", http.StatusSeeOther) } +func (h *Handler) UpdateUserTheme(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + + mode := strings.TrimSpace(r.FormValue("mode")) + if !isAllowedMode(mode) { + http.Redirect(w, r, "/settings/user?error=Invalid+theme+mode", http.StatusSeeOther) + return + } + + if err := h.repo.UpdateUserThemeMode(r.Context(), user.ID, mode); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+save+theme", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/settings/user?message=Theme+updated", http.StatusSeeOther) +} + +func (h *Handler) UpdateUserPassword(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + if h.auth.ComparePassword(user.PasswordHash, currentPassword) != nil { + http.Redirect(w, r, "/settings/user?error=Current+password+is+incorrect", http.StatusSeeOther) + return + } + if len(newPassword) < 8 { + http.Redirect(w, r, "/settings/user?error=New+password+must+be+at+least+8+characters", http.StatusSeeOther) + return + } + if newPassword != confirmPassword { + http.Redirect(w, r, "/settings/user?error=Passwords+do+not+match", http.StatusSeeOther) + return + } + + hash, err := h.auth.HashPassword(newPassword) + if err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+update+password", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserPassword(r.Context(), user.ID, hash); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+save+password", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/settings/user?message=Password+updated", http.StatusSeeOther) +} + +func (h *Handler) UpdateUserOTP(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + action := chi.URLParam(r, "action") + switch action { + case "disable": + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, false); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+disable+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=2FA+disabled", http.StatusSeeOther) + return + case "reset": + key, err := h.auth.NewOTP(user.Email) + if err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, key.Secret(), false); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=Scan+the+new+QR+code+to+enable+2FA", http.StatusSeeOther) + return + case "enable": + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) { + http.Redirect(w, r, "/settings/user?error=Invalid+OTP+code", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, true); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+enable+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=2FA+enabled", http.StatusSeeOther) + return + default: + http.NotFound(w, r) + } +} + func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title string, content any, user ...*models.User) { var currentUser *models.User if len(user) > 0 { @@ -812,7 +993,7 @@ func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title str org := h.currentOrganization(r.Context()) groups, _ := h.repo.ListGroups(r.Context(), org.ID) tags, _ := h.repo.ListTags(r.Context(), org.ID) - mode := normalizeMode(org.ThemeMode) + mode := effectiveThemeMode(currentUser, org) theme := normalizeTheme(org.Theme, mode) h.renderer.Render(w, page, views.ViewData{ @@ -912,6 +1093,7 @@ func (h *Handler) uptimePageData(ctx context.Context) uptimePageData { return uptimePageData{ Summary: summary, + Chart: buildUptimeChart(checksByMonitor), Periods: periods, Monitors: cards, Incidents: incidents, @@ -1041,6 +1223,92 @@ func reverseChecks(checks []models.UptimeCheck) []models.UptimeCheck { return reversed } +func buildUptimeChart(checksByMonitor map[int64][]models.UptimeCheck) uptimeChartData { + type bucket struct { + up int + down int + total int + } + + buckets := map[time.Time]*bucket{} + for _, checks := range checksByMonitor { + for _, check := range checks { + minute := check.CheckedAt.Truncate(time.Minute) + if buckets[minute] == nil { + buckets[minute] = &bucket{} + } + buckets[minute].total++ + if check.Status == "up" { + buckets[minute].up++ + } else { + buckets[minute].down++ + } + } + } + + if len(buckets) == 0 { + return uptimeChartData{} + } + + timestamps := make([]time.Time, 0, len(buckets)) + for ts := range buckets { + timestamps = append(timestamps, ts) + } + sort.Slice(timestamps, func(i, j int) bool { + return timestamps[i].Before(timestamps[j]) + }) + if len(timestamps) > 30 { + timestamps = timestamps[len(timestamps)-30:] + } + + points := make([]uptimeChartPoint, 0, len(timestamps)) + for _, ts := range timestamps { + current := buckets[ts] + availability := 0.0 + if current.total > 0 { + availability = float64(current.up) * 100 / float64(current.total) + } + height := int(availability) + if current.total > 0 && height < 8 { + height = 8 + } + points = append(points, uptimeChartPoint{ + Label: ts.Format("15:04"), + Availability: availability, + AvailabilityText: fmt.Sprintf("%.0f%%", availability), + UpCount: current.up, + DownCount: current.down, + TotalCount: current.total, + AvailabilityHeight: height, + }) + } + + labels := make([]string, 0, len(points)) + values := make([]float64, 0, len(points)) + pointColors := make([]string, 0, len(points)) + for i := range points { + points[i].ShowLabel = i == 0 || i == len(points)-1 || i == len(points)/2 || i%6 == 0 + labels = append(labels, points[i].Label) + values = append(values, points[i].Availability) + if points[i].DownCount > 0 { + pointColors = append(pointColors, "#ef4444") + } else { + pointColors = append(pointColors, "rgb(var(--color-primary-500))") + } + } + + labelsJSON, _ := json.Marshal(labels) + valuesJSON, _ := json.Marshal(values) + pointColorsJSON, _ := json.Marshal(pointColors) + + return uptimeChartData{ + Points: points, + LabelsJSON: template.JS(labelsJSON), + ValuesJSON: template.JS(valuesJSON), + PointColorsJSON: template.JS(pointColorsJSON), + } +} + func shellForPage(page string) string { switch page { case "login", "login_otp", "register": @@ -1079,12 +1347,56 @@ func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, e `, nodeID) } -func (h *Handler) settingsRuns(ctx context.Context) []models.CommandRun { +func (h *Handler) settingsLogs(ctx context.Context) []settingsCommandLog { runs, err := h.nodes.ListCommandHistory(ctx, h.org.ID) if err != nil { return nil } - return runs + if len(runs) == 0 { + return nil + } + + var logs []settingsCommandLog + for _, run := range runs { + target := run.NodeName + if strings.TrimSpace(run.GroupName) != "" { + target = run.GroupName + " / " + target + } + commandText := strings.TrimSpace(run.CommandText) + if commandText == "" { + commandText = strings.TrimSpace(run.Action) + } + + if len(logs) > 0 { + last := &logs[len(logs)-1] + if last.Target == target && last.Status == run.Status && last.StartedAt.Equal(run.StartedAt) { + if commandText != "" && !strings.Contains(last.CommandText, commandText) { + if last.CommandText != "" { + last.CommandText += "\n" + } + last.CommandText += commandText + } + output := strings.TrimSpace(run.Output) + if output != "" && !strings.Contains(last.Output, output) { + if last.Output != "" { + last.Output += "\n\n" + } + last.Output += output + } + continue + } + } + + logs = append(logs, settingsCommandLog{ + Target: target, + StartedAt: run.StartedAt, + Status: run.Status, + CommandText: commandText, + Output: strings.TrimSpace(run.Output), + }) + } + + return logs } func (h *Handler) nodesDB() *sql.DB { @@ -1099,21 +1411,6 @@ func encodeBase64(raw []byte) string { return base64.StdEncoding.EncodeToString(raw) } -const themePreview = ` -:root { - --color-primary-50: 236 253 245; - --color-primary-100: 209 250 229; - --color-primary-200: 167 243 208; - --color-primary-300: 110 231 183; - --color-primary-400: 52 211 153; - --color-primary-500: 16 185 129; - --color-primary-600: 5 150 105; - --color-primary-700: 4 120 87; - --color-primary-800: 6 95 70; - --color-primary-900: 6 78 59; - --color-primary-950: 2 44 34; -}` - func buildSchedule(triggerType, kind, hour, minute, weekday, intervalValue, intervalUnit string) string { if triggerType == "triggered" { return "" @@ -1174,6 +1471,13 @@ func normalizeMode(mode string) string { return "dark" } +func effectiveThemeMode(user *models.User, org models.Organization) string { + if user != nil && strings.TrimSpace(user.ThemeMode) != "" { + return normalizeMode(user.ThemeMode) + } + return normalizeMode(org.ThemeMode) +} + func saveKeyUpload(r *http.Request, field string) (string, error) { file, header, err := r.FormFile(field) if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go index e338cac..3333159 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -27,6 +27,7 @@ type User struct { Role Role OTPSecret string OTPEnabled bool + ThemeMode string CreatedAt time.Time } diff --git a/internal/services/node.go b/internal/services/node.go index 72685d4..aafedb3 100644 --- a/internal/services/node.go +++ b/internal/services/node.go @@ -188,6 +188,8 @@ func (s *NodeService) EnsureUptimeMonitorForNode(ctx context.Context, node *mode organization_id = excluded.organization_id, name = excluded.name, target = excluded.target, + interval_seconds = 60, + enabled = 1, updated_at = CURRENT_TIMESTAMP `, node.OrganizationID, node.ID, name, target) return err diff --git a/internal/services/repository.go b/internal/services/repository.go index f66b4a4..4c888c0 100644 --- a/internal/services/repository.go +++ b/internal/services/repository.go @@ -71,9 +71,9 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) { func (r *Repository) CreateUser(ctx context.Context, user *models.User) error { result, err := r.db.ExecContext(ctx, ` - INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled) + INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled, user.ThemeMode) if err != nil { return err } @@ -85,12 +85,12 @@ func (r *Repository) CreateUser(ctx context.Context, user *models.User) error { func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { user := &models.User{} err := r.db.QueryRowContext(ctx, ` - SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at + SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at FROM users WHERE email = ? `, email).Scan( &user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role, - &user.OTPSecret, &user.OTPEnabled, &user.CreatedAt, + &user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt, ) if err != nil { return nil, err @@ -102,12 +102,12 @@ func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models. func (r *Repository) GetUserByID(ctx context.Context, id int64) (*models.User, error) { user := &models.User{} err := r.db.QueryRowContext(ctx, ` - SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at + SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at FROM users WHERE id = ? `, id).Scan( &user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role, - &user.OTPSecret, &user.OTPEnabled, &user.CreatedAt, + &user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt, ) if err != nil { return nil, err @@ -121,6 +121,21 @@ func (r *Repository) EnableUserOTP(ctx context.Context, id int64) error { return err } +func (r *Repository) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, id) + return err +} + +func (r *Repository) UpdateUserThemeMode(ctx context.Context, id int64, themeMode string) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET theme_mode = ? WHERE id = ?`, themeMode, id) + return err +} + +func (r *Repository) UpdateUserOTP(ctx context.Context, id int64, secret string, enabled bool) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET otp_secret = ?, otp_enabled = ? WHERE id = ?`, secret, enabled, id) + return err +} + func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error { var adminCount int if err := r.db.QueryRowContext(ctx, ` diff --git a/internal/views/layouts/app.gohtml b/internal/views/layouts/app.gohtml index 0d724fa..cd2b719 100644 --- a/internal/views/layouts/app.gohtml +++ b/internal/views/layouts/app.gohtml @@ -26,10 +26,6 @@ - {{else if eq .CurrentPath "/settings"}} - - Themes - {{else if eq .CurrentPath "/dashboard"}}
@@ -85,7 +84,15 @@
-
{{.Title}}
+
+ {{if eq .CurrentPath "/dashboard"}}{{end}} + {{if eq .CurrentPath "/groups"}}{{end}} + {{if eq .CurrentPath "/automations"}}{{end}} + {{if eq .CurrentPath "/uptime"}}{{end}} + {{if contains .CurrentPath "/settings"}}{{end}} + {{if contains .CurrentPath "/nodes/"}}{{end}} + {{.Title}} +
{{template "contextAction" .}} @@ -104,8 +111,8 @@ {{if not (contains .CurrentPath "/console")}}
- {{with .Organization}}{{.Name}}{{end}} - {{with .User}}{{.Name}} · {{.Role}}{{end}} + {{with .Organization}}{{.Name}}{{end}} + {{with .User}}{{.Name}} · {{.Role}}{{end}}
{{end}} @@ -118,7 +125,7 @@ @@ -145,13 +152,13 @@
diff --git a/internal/views/pages/register.gohtml b/internal/views/pages/register.gohtml index 5ee649d..21ef134 100644 --- a/internal/views/pages/register.gohtml +++ b/internal/views/pages/register.gohtml @@ -1,24 +1,24 @@ {{define "content"}}
-

Create account

+

Create account

{{with .Content}}{{with .Error}}
{{.}}
{{end}}{{end}}
- +
- +
- +
- +
{{end}} diff --git a/internal/views/pages/settings.gohtml b/internal/views/pages/settings.gohtml index 5105e44..8193821 100644 --- a/internal/views/pages/settings.gohtml +++ b/internal/views/pages/settings.gohtml @@ -2,84 +2,119 @@ {{$data := .Content}}
-
Themes
-

Appearance

-

Dark or light only.

+
System
+

Settings

+

Application-wide status and command logs.

-
-
-
-
-

Theme Presets

-
- -
-
- - -
-
- - -
-
- - -
-
-
-
- -
+
+
-

Current

-
-
-
-
+
Nodes
+
{{$data.NodeCount}}
+
+
+
+
+
+
+
Groups
+
{{$data.GroupCount}}
+
+
+
+
+
+
+
Jobs
+
{{$data.JobCount}}
+
+
+
+
+
+
+
Users
+
{{$data.UserCount}}
+
+
+
+
+ +
+
+
+
+

Organization

+
+
+ Name + {{with $.Organization}}{{.Name}}{{end}} +
+
+ Topology + Single organization +
+
+ Database + SQLite +
-
- Theme: {{$data.CurrentTheme}}
- Mode: {{$data.CurrentMode}} +
+
+
+
+
+
+

Access

+
+
+ Roles + Admin / Editor / Viewer +
+
+ 2FA + OTP +
+
+ First user + Always Admin +
-
+
-

Command History

+

Command History

- - - - + + + + - {{if $data.Runs}} - {{range $data.Runs}} + {{if $data.Logs}} + {{range $data.Logs}} - + - + {{end}} {{else}} diff --git a/internal/views/pages/setup_otp.gohtml b/internal/views/pages/setup_otp.gohtml index 16c4f10..b9b47c2 100644 --- a/internal/views/pages/setup_otp.gohtml +++ b/internal/views/pages/setup_otp.gohtml @@ -1,30 +1,30 @@ {{define "content"}} {{$c := .Content}}
- Security -

Enable OTP 2FA

+ Security +

Enable OTP 2FA

{{with $c}}{{with .Error}}
{{.}}
{{end}}{{end}}
-
- OTP QR code -
+
+ OTP QR code +
-
-
- - -
-
- - -
- - +
+
+ + +
+
+ + +
+ +
diff --git a/internal/views/pages/uptime.gohtml b/internal/views/pages/uptime.gohtml index 0659360..e7f9050 100644 --- a/internal/views/pages/uptime.gohtml +++ b/internal/views/pages/uptime.gohtml @@ -4,7 +4,7 @@
-
Monitors
+
Monitors
{{$data.Summary.TotalMonitors}}
@@ -12,7 +12,7 @@
-
Up
+
Up
{{$data.Summary.UpMonitors}}
@@ -20,7 +20,7 @@
-
Down
+
Down
{{$data.Summary.DownMonitors}}
@@ -28,7 +28,7 @@
-
Avg latency
+
Avg latency
{{if $data.Summary.AvgLatencyMS}}{{$data.Summary.AvgLatencyMS}}ms{{else}}-{{end}}
@@ -40,64 +40,26 @@
-

Monitors

- SSH endpoint checks +
+

Fleet availability

+
Last 30 one-minute samples
+
+ 1 minute interval
-
- {{if $data.Monitors}} - {{range $data.Monitors}} -
-
-
-
-
{{.Monitor.Name}}
-
{{.Monitor.Target}}{{if .Monitor.GroupName}} · {{.Monitor.GroupName}}{{end}}
-
- - {{if eq .Monitor.LastStatus "down"}}Down{{else if eq .Monitor.LastStatus "up"}}Up{{else}}Pending{{end}} - -
- -
-
- Availability - {{.AvailabilityText}} -
-
- Latency - {{if .Monitor.LastLatencyMS}}{{.Monitor.LastLatencyMS}}ms{{else}}-{{end}} -
-
- Checked - {{.LastCheckedText}} -
-
- Interval - {{.IntervalText}} -
-
- - - -
- {{.StateDurationText}} - {{if .Monitor.LastError}}{{.Monitor.LastError}}{{else}}{{.Monitor.NodeName}}{{end}} -
-
-
- {{end}} +
+ {{if $data.Chart.Points}} +
+ +
{{else}} -
-
No monitors yet. Add a VM to start tracking uptime.
-
+
No checks yet. Add a VM or run checks.
{{end}}
@@ -107,17 +69,17 @@
-

Availability

+

Availability

-
Group/VMidTimeStatusCommandGroup/VMidTimeStatusCommand
{{if .GroupName}}{{.GroupName}} / {{end}}{{.NodeName}}{{.Target}} {{.StartedAt.Format "2006-01-02 15:04:05"}} {{.Status}}{{if .CommandText}}{{.CommandText}}{{else}}{{.Action}}{{end}} + {{.CommandText}} + {{if .Output}} +
{{.Output}}
+ {{end}} +
+
- - - - - - + + + + + + @@ -139,22 +101,84 @@ +
+
+
+
+

Nodes

+ {{len $data.Monitors}} total +
+
+
PeriodAvailabilityDowntimeIncidentsLongestAvgPeriodAvailabilityDowntimeIncidentsLongestAvg
+ + + + + + + + + + + + + {{if $data.Monitors}} + {{range $data.Monitors}} + + + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
NodeStatusAvailabilityLatencyCheckedWindowRecent
+
{{.Monitor.Name}}
+
{{.Monitor.Target}}{{if .Monitor.GroupName}} · {{.Monitor.GroupName}}{{end}}
+
+ + {{if eq .Monitor.LastStatus "down"}}Down{{else if eq .Monitor.LastStatus "up"}}Up{{else}}Pending{{end}} + + {{.AvailabilityText}}{{if .Monitor.LastLatencyMS}}{{.Monitor.LastLatencyMS}}ms{{else}}-{{end}}{{.LastCheckedText}}{{.StateDurationText}} + +
No monitors yet. Add a VM to start tracking uptime.
+
+
+
+
+
-

Incidents

+

Incidents

{{len $data.Incidents}} recent
- +
- - - - - + + + + + diff --git a/internal/views/pages/user_settings.gohtml b/internal/views/pages/user_settings.gohtml new file mode 100644 index 0000000..3df9b89 --- /dev/null +++ b/internal/views/pages/user_settings.gohtml @@ -0,0 +1,192 @@ +{{define "content"}} +{{$data := .Content}} +
+
+
Account
+

User Settings

+

Password, 2FA, and your display mode.

+
+
+ +{{if $data.Message}} +
{{$data.Message}}
+{{end}} +{{if $data.Error}} +
{{$data.Error}}
+{{end}} + +
+
+
+
+
+
+ +
+
+
{{with .User}}{{.Name}}{{end}}
+
{{with .User}}{{.Email}}{{end}}
+
{{with .User}}{{.Role}}{{end}}
+
+
+ +
+
+
+ +
+
+
+
+
+
+

Profile

+
Current account details.
+
+
+
+
+
+
Name
+
{{with .User}}{{.Name}}{{end}}
+
+
+
+
+
Email
+
{{with .User}}{{.Email}}{{end}}
+
+
+
+
+
Role
+
{{with .User}}{{.Role}}{{end}}
+
+
+
+
+
2FA
+
{{if $data.OTPEnabled}}Enabled{{else}}Disabled{{end}}
+
+
+
+
+
+ +
+
+
+
+

Password

+
Update your sign-in password.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ +
+
+
+
+

Two-factor authentication

+
Manage OTP authentication for your account.
+
+ {{if $data.OTPEnabled}} + Enabled + {{else}} + Pending + {{end}} +
+ + {{if $data.OTPEnabled}} +
+
+ + +
+ + +
+ {{else}} +
+
+
+ OTP QR code +
+
+
+
+
+ + +
+
+ + +
+ + +
+
+ {{end}} +
+
+ +
+
+
+
+

Appearance

+
Choose how Maintainarr looks for you.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+{{end}} diff --git a/web/static/css/app.css b/web/static/css/app.css index 3dbdf3c..6d0766e 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -404,7 +404,7 @@ body[data-bs-theme="light"] .app-nav-link.is-active { min-height: 12rem; display: flex; flex-direction: column; - justify-content: space-between; + justify-content: flex-start; } .kpi-card-label { @@ -415,26 +415,6 @@ body[data-bs-theme="light"] .app-nav-link.is-active { color: var(--bs-secondary-color); } -.kpi-card-subtle { - margin-top: 0.35rem; - font-size: 0.9rem; - color: var(--bs-secondary-color); -} - -.kpi-card-chip { - display: inline-flex; - align-items: center; - min-height: 2rem; - padding: 0.35rem 0.7rem; - border-radius: 999px; - border: 1px solid var(--ma-border); - background: color-mix(in srgb, var(--ma-surface-base) 78%, var(--ma-surface-2)); - color: var(--bs-secondary-color); - font-size: 0.76rem; - font-weight: 600; - white-space: nowrap; -} - .kpi-card-featured-value { position: relative; z-index: 1; @@ -443,51 +423,13 @@ body[data-bs-theme="light"] .app-nav-link.is-active { line-height: 1; letter-spacing: -0.03em; color: var(--bs-emphasis-color); -} - -.kpi-card-featured-trend { - position: relative; - z-index: 1; - display: flex; - align-items: center; - gap: 0.7rem; - flex-wrap: wrap; -} - -.kpi-card-trend { - display: inline-flex; - align-items: center; - min-height: 2rem; - padding: 0.3rem 0.65rem; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 700; -} - -.kpi-card-trend.is-up { - color: #86efac; - background: rgba(20, 83, 45, 0.58); -} - -.kpi-card-trend.is-down { - color: #fca5a5; - background: rgba(127, 29, 29, 0.58); -} - -.kpi-card-trend.is-flat { - color: #cbd5e1; - background: rgba(51, 65, 85, 0.6); -} - -.kpi-card-trend-copy { - font-size: 0.88rem; - color: var(--bs-secondary-color); + margin-top: auto; } .kpi-graph { position: absolute; inset: 0; - opacity: 0.55; + opacity: 1; pointer-events: none; } @@ -498,9 +440,8 @@ body[data-bs-theme="light"] .app-nav-link.is-active { .kpi-card-featured .kpi-graph { inset: auto 0 0 0; - height: 68%; - opacity: 0.92; - mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.45) 26%, rgba(0, 0, 0, 1) 100%); + height: 42%; + opacity: 0.96; } .node-tile { @@ -563,9 +504,10 @@ body[data-bs-theme="light"] .app-nav-link.is-active { } .node-brand-icon { - width: 3rem; - height: 3rem; - font-size: 2.1rem; + width: 100%; + height: 80%; + font-size: 3.6rem; + align-self: center; } .node-loading-icon { @@ -680,6 +622,21 @@ body[data-bs-theme="light"] .app-nav-link.is-active { font-size: 0.92rem; } +.system-summary-copy { + width: 100%; + padding: 0.55rem 0.7rem; + border: 0; + border-radius: 0.9rem; + background: transparent; + text-align: left; + transition: background-color 0.18s ease, transform 0.18s ease; +} + +.system-summary-copy:hover { + background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2)); + transform: translateX(2px); +} + .progress { --bs-progress-height: 0.6rem; background: color-mix(in srgb, black 18%, var(--ma-surface-1)); @@ -875,17 +832,47 @@ body[data-bs-theme="light"] .option-check { height: 100vh; width: 100vw; overflow: hidden; + background: #0a0f1a; +} + +.console-page { + height: 100vh; + width: 100vw; + background: + radial-gradient(circle at top, rgba(var(--color-primary-700), 0.18), transparent 22%), + linear-gradient(180deg, #070b13 0%, #0a0f1a 100%); +} + +.console-page-terminal { + height: 100%; + width: 100%; + overflow: hidden; +} + +.console-terminal-fullscreen { + min-height: 100vh; + height: 100vh; + max-height: none; + border-radius: 0; + background: transparent; } .console-terminal .xterm { height: 100%; background: #0b1220; - padding: 0.9rem 1rem; + padding: 0; } .console-terminal .xterm-viewport { background: #0b1220; - border-radius: 1.25rem; + border-radius: 0; +} + +.console-terminal-fullscreen .xterm, +.console-terminal-fullscreen .xterm-screen, +.console-terminal-fullscreen .xterm-viewport, +.console-terminal-fullscreen .xterm-scroll-area { + background: #0a0f1a; } .preview-card { @@ -957,6 +944,61 @@ body[data-bs-theme="light"] .theme-card { box-shadow: 0 0 0 0.2rem rgba(var(--color-primary-500), 0.2); } +.settings-nav-card, +.settings-section-card, +.settings-info-tile, +.otp-qr-card { + background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 90%, transparent), var(--ma-surface-1)); +} + +.settings-nav-list { + display: grid; + gap: 0.55rem; +} + +.settings-nav-link { + display: flex; + align-items: center; + gap: 0.85rem; + min-height: 2.9rem; + padding: 0.8rem 0.95rem; + border: 1px solid var(--ma-border); + border-radius: 0.95rem; + color: var(--bs-secondary-color); + font-weight: 600; + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} + +.settings-nav-link:hover, +.settings-nav-link.is-active { + color: var(--bs-emphasis-color); + background: color-mix(in srgb, rgb(var(--color-primary-500)) 8%, transparent); + border-color: rgba(var(--color-primary-500), 0.35); +} + +.settings-nav-link i { + font-size: 1.15rem; +} + +.settings-info-tile { + min-height: 100%; + padding: 1rem; + border: 1px solid var(--ma-border); + border-radius: 1rem; +} + +.otp-qr-card { + padding: 1rem; + border: 1px solid var(--ma-border); + border-radius: 1rem; +} + +.otp-qr-card img { + width: 100%; + max-width: 220px; + height: auto; +} + .uptime-summary-card { background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 92%, transparent), var(--ma-surface-1)); } @@ -977,6 +1019,27 @@ body[data-bs-theme="light"] .theme-card { color: var(--bs-emphasis-color); } +.uptime-chart-shell { + display: grid; + gap: 0.75rem; +} + +.uptime-line-chart { + position: relative; + height: 18rem; + padding: 0.75rem; + border-radius: 1rem; + background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-base) 84%, var(--ma-surface-2)), color-mix(in srgb, var(--ma-surface-1) 96%, transparent)); + overflow: hidden; + border: 1px solid var(--ma-border); +} + +.uptime-line-chart-canvas { + display: block; + width: 100%; + height: 100%; +} + .uptime-monitor-card { display: grid; gap: 1rem; @@ -1064,6 +1127,12 @@ body[data-bs-theme="light"] .theme-card { padding: 0.2rem 0; } +.uptime-check-strip-table { + min-width: 10rem; + min-height: 1rem; + gap: 0.18rem; +} + .uptime-check-pill { flex: 1 1 0; min-width: 0; @@ -1084,6 +1153,11 @@ body[data-bs-theme="light"] .theme-card { background: rgba(100, 116, 139, 0.35); } +.uptime-check-strip-table .uptime-check-pill { + height: 0.8rem; + min-width: 0.2rem; +} + .uptime-monitor-foot { font-size: 0.82rem; } @@ -1094,6 +1168,37 @@ body[data-bs-theme="light"] .theme-card { letter-spacing: 0.08em; text-transform: uppercase; color: var(--bs-secondary-color); + border-bottom: 1px solid var(--ma-border); + padding: 0 0.85rem 0.85rem; + white-space: nowrap; +} + +.uptime-table td { + padding: 0.9rem 0.85rem; + border-color: var(--ma-border); + vertical-align: middle; +} + +.uptime-table tbody tr { + transition: background-color 0.18s ease; +} + +.uptime-table tbody tr:hover { + background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2)); +} + +.uptime-table tbody tr:last-child td { + border-bottom-color: transparent; +} + +.uptime-table-compact td, +.uptime-table-compact th { + padding-left: 0.7rem; + padding-right: 0.7rem; +} + +.uptime-table-nodes td { + font-size: 0.92rem; } body[data-bs-theme="light"] .uptime-monitor-card, @@ -1105,6 +1210,10 @@ body[data-bs-theme="light"] .uptime-monitor-stat { background: color-mix(in srgb, white 82%, var(--ma-surface-1)); } +body[data-bs-theme="light"] .uptime-line-chart { + background: linear-gradient(180deg, color-mix(in srgb, white 86%, var(--ma-surface-1)), var(--ma-surface-1)); +} + @media (max-width: 991.98px) { .app-shell { flex-direction: column; diff --git a/web/static/img/favicon-rounded.png b/web/static/img/favicon-rounded.png new file mode 100644 index 0000000000000000000000000000000000000000..78f0bb9fb384c181a3ade11f0fcaf292f37af96b GIT binary patch literal 38663 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 { const authToggle = document.querySelector("[data-auth-toggle]"); const themeRadios = document.querySelectorAll('input[name="theme"]'); const themeModeInput = document.querySelector('input[name="mode"]'); + const uptimeChart = document.getElementById("uptime-chart"); const attachXtermConsole = (consoleOutput) => { const wsPath = consoleOutput.dataset.ws; @@ -145,7 +146,6 @@ document.addEventListener("DOMContentLoaded", () => { disk: [], uptime: [], }; - let previousCpu = null; const uptimeLabel = (seconds) => { const days = Math.floor(seconds / 86400); @@ -155,32 +155,44 @@ document.addEventListener("DOMContentLoaded", () => { return `${hours}h ${minutes}m`; }; - const renderSparkline = (values) => { + const renderSparkline = (values, key) => { if (values.length === 0) { return ""; } - const max = Math.max(...values, 1); - const points = values.map((value, index) => { - const x = (index / Math.max(values.length - 1, 1)) * 100; - const y = 100 - (value / max) * 80 - 10; - return `${x},${y}`; + + let min = 0; + let max = 100; + + if (key === "uptime") { + min = Math.min(...values); + max = Math.max(...values); + if (max-min < 5) { + max = min + 5; + } + } + + const path = values.map((value, index) => { + const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 100; + const normalized = (value - min) / Math.max(max - min, 1); + const y = 90 - normalized * 72; + return `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`; }).join(" "); return ` - + `; }; const pushValue = (key, value) => { history[key].push(value); - if (history[key].length > 18) { + if (history[key].length > 60) { history[key].shift(); } const card = nodeLive.querySelector(`.kpi-card[data-kpi="${key}"] .kpi-graph`); if (card) { - card.innerHTML = renderSparkline(history[key]); + card.innerHTML = renderSparkline(history[key], key); } }; @@ -203,21 +215,11 @@ document.addEventListener("DOMContentLoaded", () => { const ramValue = nodeLive.querySelector('[data-kpi-value="ram"]'); const diskValue = nodeLive.querySelector('[data-kpi-value="disk"]'); const uptimeValue = nodeLive.querySelector('[data-kpi-value="uptime"]'); - const cpuTrend = nodeLive.querySelector('[data-kpi-trend="cpu"]'); if (cpuValue) cpuValue.textContent = `${cpu.toFixed(1)}%`; if (ramValue) ramValue.textContent = `${ram.toFixed(1)}%`; if (diskValue) diskValue.textContent = `${disk.toFixed(1)}%`; if (uptimeValue) uptimeValue.textContent = uptimeLabel(uptime); - if (cpuTrend) { - const delta = previousCpu === null ? 0 : cpu - previousCpu; - const trendClass = delta > 0.15 ? "is-up" : delta < -0.15 ? "is-down" : "is-flat"; - const trendLabel = `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`; - cpuTrend.classList.remove("is-up", "is-down", "is-flat"); - cpuTrend.classList.add(trendClass); - cpuTrend.textContent = trendLabel; - } - previousCpu = cpu; pushValue("cpu", cpu); pushValue("ram", ram); @@ -317,6 +319,133 @@ document.addEventListener("DOMContentLoaded", () => { }); } + if (uptimeChart instanceof HTMLCanvasElement && typeof Chart !== "undefined") { + try { + const labels = JSON.parse(uptimeChart.dataset.labels || "[]"); + const values = JSON.parse(uptimeChart.dataset.values || "[]"); + const pointColors = JSON.parse(uptimeChart.dataset.pointColors || "[]"); + const style = getComputedStyle(document.body); + const primaryRGB = style.getPropertyValue("--color-primary-500").trim() || "16 185 129"; + const chartStroke = `rgb(${primaryRGB})`; + const chartFill = `rgba(${primaryRGB.replace(/\s+/g, ", ")}, 0.16)`; + const gridColor = style.getPropertyValue("--ma-border").trim() || "rgba(148, 163, 184, 0.16)"; + const tickColor = style.getPropertyValue("--bs-secondary-color").trim() || "#94a3b8"; + + new Chart(uptimeChart, { + type: "line", + data: { + labels, + datasets: [{ + data: values, + borderColor: chartStroke, + backgroundColor: chartFill, + fill: true, + borderWidth: 3, + tension: 0.35, + pointRadius: 3, + pointHoverRadius: 4, + pointBackgroundColor: pointColors, + pointBorderColor: pointColors, + pointBorderWidth: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + callbacks: { + label(context) { + return `${Number(context.parsed.y || 0).toFixed(0)}% availability`; + }, + }, + }, + }, + interaction: { + intersect: false, + mode: "index", + }, + scales: { + x: { + grid: { + display: false, + }, + border: { + display: false, + }, + ticks: { + color: tickColor, + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 6, + }, + }, + y: { + min: 0, + max: 100, + border: { + display: false, + }, + ticks: { + color: tickColor, + stepSize: 25, + callback(value) { + return `${value}%`; + }, + }, + grid: { + color: gridColor, + drawTicks: false, + }, + }, + }, + }, + }); + } catch (_) { + // Ignore malformed chart payloads. + } + } + + document.addEventListener("click", async (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const copyTrigger = target.closest("[data-copy-value]"); + if (!(copyTrigger instanceof HTMLElement)) { + return; + } + + const value = (copyTrigger.dataset.copyValue || "").trim(); + if (!value) { + return; + } + + try { + await navigator.clipboard.writeText(value); + } catch (_) { + return; + } + + if (typeof bootstrap !== "undefined") { + const tooltip = bootstrap.Tooltip.getOrCreateInstance(copyTrigger); + const previousTitle = copyTrigger.getAttribute("data-bs-title") || copyTrigger.getAttribute("title") || "Copy"; + copyTrigger.setAttribute("data-bs-title", "Copied"); + tooltip.setContent?.({ ".tooltip-inner": "Copied" }); + tooltip.show(); + window.setTimeout(() => { + copyTrigger.setAttribute("data-bs-title", previousTitle); + tooltip.setContent?.({ ".tooltip-inner": previousTitle }); + }, 1200); + } + }); + if (typeof bootstrap !== "undefined") { document.querySelectorAll('[data-bs-toggle-tooltip="tooltip"]').forEach((element) => { new bootstrap.Tooltip(element); From fd3ef1adfd078a512342ec0bcc921f2d3c561700 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 18:48:18 -0500 Subject: [PATCH 04/13] fix(nodes): backfill MAC address and enlarge distro logos --- internal/handlers/handlers.go | 3 +++ internal/services/node.go | 6 ++++-- web/static/css/app.css | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d20c8aa..5cee43f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -437,6 +437,9 @@ func (h *Handler) NodeOverview(w http.ResponseWriter, r *http.Request) { return } + if node.SSHUsername != "" && node.SSHPassword != "" && (strings.TrimSpace(node.MACAddress) == "" || strings.TrimSpace(node.PackageManager) == "" || strings.TrimSpace(node.Distro) == "" || strings.EqualFold(strings.TrimSpace(node.Distro), "linux")) { + _, _ = h.nodes.RefreshNodeInventory(r.Context(), node) + } if node.SSHUsername != "" && node.SSHPassword != "" && h.nodes.StatsStale(node, time.Second) { _, _ = h.nodes.RefreshNodeStats(r.Context(), node) } diff --git a/internal/services/node.go b/internal/services/node.go index aafedb3..c2095c7 100644 --- a/internal/services/node.go +++ b/internal/services/node.go @@ -335,6 +335,7 @@ func (s *NodeService) RefreshNodeInventory(ctx context.Context, node *models.Nod output, err := s.RunSSHCommand(ctx, node, strings.Join([]string{ `if [ -r /etc/os-release ]; then . /etc/os-release; echo DISTRO="${PRETTY_NAME:-$ID}"; else echo DISTRO="$(uname -s)"; fi`, `echo HOSTNAME="$(hostname 2>/dev/null || uname -n)"`, + `PRIMARY_IFACE="$(ip route get 1.1.1.1 2>/dev/null | awk '/dev/ {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')"; if [ -z "$PRIMARY_IFACE" ]; then PRIMARY_IFACE="$(ip -o link show 2>/dev/null | awk -F': ' '$2 != "lo" {print $2; exit}')"; fi; MAC_ADDR="$(cat "/sys/class/net/${PRIMARY_IFACE}/address" 2>/dev/null)"; echo MAC_ADDR="${MAC_ADDR}"`, `echo ARCH="$(uname -m 2>/dev/null)"`, `HOST_MODEL="$(cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null || hostnamectl 2>/dev/null | awk -F: '/Chassis|Hardware Model/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')"; echo HOST_MODEL="${HOST_MODEL}"`, `echo KERNEL="$(uname -r 2>/dev/null)"`, @@ -385,6 +386,7 @@ func (s *NodeService) RefreshNodeInventory(ctx context.Context, node *models.Nod node.Distro = fallbackInventoryValue(values["DISTRO"], node.Distro, "Linux") node.Hostname = fallbackInventoryValue(values["HOSTNAME"], node.Hostname, node.IPAddress) + node.MACAddress = fallbackInventoryValue(values["MAC_ADDR"], node.MACAddress, "") node.PackageManager = fallbackInventoryValue(values["PKG_MGR"], node.PackageManager, "") node.Architecture = fallbackInventoryValue(values["ARCH"], node.Architecture, "") node.HostModel = fallbackInventoryValue(values["HOST_MODEL"], node.HostModel, "") @@ -404,10 +406,10 @@ func (s *NodeService) RefreshNodeInventory(ctx context.Context, node *models.Nod _, err = s.db.ExecContext(ctx, ` UPDATE nodes - SET distro = ?, hostname = ?, package_manager = ?, architecture = ?, host_model = ?, kernel_version = ?, cpu_model = ?, gpu_model = ?, default_shell = ?, package_count = ?, memory_total_mb = ?, disk_total_gb = ?, + SET distro = ?, hostname = ?, mac_address = ?, package_manager = ?, architecture = ?, host_model = ?, kernel_version = ?, cpu_model = ?, gpu_model = ?, default_shell = ?, package_count = ?, memory_total_mb = ?, disk_total_gb = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND organization_id = ? - `, node.Distro, node.Hostname, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.ID, node.OrganizationID) + `, node.Distro, node.Hostname, node.MACAddress, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.ID, node.OrganizationID) if err != nil { return output, err } diff --git a/web/static/css/app.css b/web/static/css/app.css index 6d0766e..9c65472 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -505,8 +505,8 @@ body[data-bs-theme="light"] .app-nav-link.is-active { .node-brand-icon { width: 100%; - height: 80%; - font-size: 3.6rem; + height: 92%; + font-size: 4.5rem; align-self: center; } From 208bba9b9a3bec39d1fe93018d50c11f18af14fe Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 19:59:08 -0500 Subject: [PATCH 05/13] feat(app): add node management and update controls --- internal/app/app.go | 12 + internal/db/db.go | 24 ++ internal/handlers/handlers.go | 511 ++++++++++++++++++++++- internal/models/models.go | 93 +++-- internal/services/node.go | 565 +++++++++++++++++++++++--- internal/services/repository.go | 36 +- internal/views/layouts/app.gohtml | 109 ++++- internal/views/pages/dashboard.gohtml | 5 +- internal/views/pages/groups.gohtml | 51 ++- internal/views/pages/node.gohtml | 92 +++++ internal/views/pages/settings.gohtml | 210 ++++------ internal/views/pages/updates.gohtml | 263 ++++++++++++ internal/views/views.go | 84 ++++ web/static/css/app.css | 314 +++++++++++++- web/static/js/app.js | 215 +++++++++- 15 files changed, 2318 insertions(+), 266 deletions(-) create mode 100644 internal/views/pages/updates.gohtml diff --git a/internal/app/app.go b/internal/app/app.go index fdfc6c7..db5393f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,6 +96,7 @@ func New() (*App, error) { protected.Get("/nodes/{nodeID}/console/ws", handler.NodeConsoleWebSocket) protected.Get("/groups", handler.GroupsPage) protected.Get("/automations", handler.AutomationsPage) + protected.Get("/updates", handler.UpdatesPage) protected.Get("/uptime", handler.UptimePage) protected.Get("/settings", handler.SettingsPage) protected.Get("/settings/user", handler.UserSettingsPage) @@ -107,11 +108,22 @@ func New() (*App, error) { protected.Group(func(editor chi.Router) { editor.Use(localmiddleware.RequireRole(models.RoleEditor)) editor.Post("/groups", handler.CreateGroup) + editor.Post("/groups/{groupID}", handler.UpdateGroup) editor.Post("/nodes", handler.CreateNode) + editor.Post("/nodes/{nodeID}", handler.UpdateNode) editor.Post("/nodes/{nodeID}/actions/{action}", handler.NodeAction) editor.Post("/nodes/{nodeID}/commands", handler.NodeQuickCommand) editor.Post("/nodes/{nodeID}/delete", handler.DeleteNode) editor.Post("/automations", handler.CreateAutomation) + editor.Post("/updates/scan", handler.ScanAllUpdates) + editor.Post("/updates/apply", handler.ApplyAllUpdates) + editor.Post("/updates/settings/window", handler.UpdateGlobalUpdateWindow) + editor.Post("/updates/nodes/{nodeID}/policy", handler.UpdateNodePolicy) + editor.Post("/updates/nodes/{nodeID}/scan", handler.ScanNodeUpdates) + editor.Post("/updates/nodes/{nodeID}/apply", handler.ApplyNodeUpdates) + editor.Post("/updates/groups/{groupID}/policy", handler.UpdateGroupPolicy) + editor.Post("/updates/groups/{groupID}/scan", handler.ScanGroupUpdates) + editor.Post("/updates/groups/{groupID}/apply", handler.ApplyGroupUpdates) editor.Post("/uptime/run", handler.RunUptimeChecks) }) }) diff --git a/internal/db/db.go b/internal/db/db.go index 1e650a4..eac39c5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -46,6 +46,9 @@ func migrate(ctx context.Context, database *sql.DB) error { name TEXT NOT NULL, theme TEXT NOT NULL DEFAULT 'emerald', theme_mode TEXT NOT NULL DEFAULT 'dark', + auto_update_window_start TEXT NOT NULL DEFAULT '', + auto_update_window_end TEXT NOT NULL DEFAULT '', + auto_update_days TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );`, `CREATE TABLE IF NOT EXISTS users ( @@ -67,6 +70,7 @@ func migrate(ctx context.Context, database *sql.DB) error { name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', color_token TEXT NOT NULL DEFAULT 'primary', + icon TEXT NOT NULL DEFAULT 'ti ti-stack-2', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_id) REFERENCES organizations(id) );`, @@ -98,7 +102,15 @@ func migrate(ctx context.Context, database *sql.DB) error { disk_usage REAL NOT NULL DEFAULT 0, uptime_seconds INTEGER NOT NULL DEFAULT 0, last_seen_at DATETIME, + updates_scan_enabled BOOLEAN NOT NULL DEFAULT 1, auto_updates_enabled BOOLEAN NOT NULL DEFAULT 0, + updates_available INTEGER NOT NULL DEFAULT 0, + updates_details TEXT NOT NULL DEFAULT '', + auto_update_window_start TEXT NOT NULL DEFAULT '', + auto_update_window_end TEXT NOT NULL DEFAULT '', + auto_update_days TEXT NOT NULL DEFAULT '', + updates_last_checked_at DATETIME, + updates_last_error TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -186,7 +198,11 @@ func migrate(ctx context.Context, database *sql.DB) error { alterStatements := []string{ `ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`, + `ALTER TABLE organizations ADD COLUMN auto_update_window_start TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE organizations ADD COLUMN auto_update_window_end TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE organizations ADD COLUMN auto_update_days TEXT NOT NULL DEFAULT '';`, `ALTER TABLE users ADD COLUMN theme_mode TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE vm_groups ADD COLUMN icon TEXT NOT NULL DEFAULT 'ti ti-stack-2';`, `ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`, @@ -198,6 +214,14 @@ func migrate(ctx context.Context, database *sql.DB) error { `ALTER TABLE nodes ADD COLUMN package_count INTEGER NOT NULL DEFAULT 0;`, `ALTER TABLE nodes ADD COLUMN memory_total_mb INTEGER NOT NULL DEFAULT 0;`, `ALTER TABLE nodes ADD COLUMN disk_total_gb INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE nodes ADD COLUMN updates_scan_enabled BOOLEAN NOT NULL DEFAULT 1;`, + `ALTER TABLE nodes ADD COLUMN updates_available INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE nodes ADD COLUMN updates_details TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN auto_update_window_start TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN auto_update_window_end TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN auto_update_days TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN updates_last_checked_at DATETIME;`, + `ALTER TABLE nodes ADD COLUMN updates_last_error TEXT NOT NULL DEFAULT '';`, `ALTER TABLE automation_jobs ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, `ALTER TABLE command_runs ADD COLUMN command_text TEXT NOT NULL DEFAULT '';`, `ALTER TABLE uptime_monitors ADD COLUMN last_error TEXT NOT NULL DEFAULT '';`, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 5cee43f..25851ec 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -44,21 +44,35 @@ type dashboardData struct { type dashboardNodeGroup struct { Name string + Icon string Nodes []models.Node } +type groupsPageData struct { + Groups []groupSummary +} + +type groupSummary struct { + Group models.VMGroup + VMCount int +} + type nodePageData struct { - Node *models.Node - CommandRuns []models.CommandRun - ConsoleToken string + Node *models.Node + CommandRuns []models.CommandRun + ConsoleToken string + SelectedGroupID int64 } type settingsData struct { - NodeCount int - GroupCount int - JobCount int - UserCount int - Logs []settingsCommandLog + Logs []settingsCommandLog + Page int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + CurrentPath string } type settingsCommandLog struct { @@ -67,6 +81,7 @@ type settingsCommandLog struct { Status string CommandText string Output string + Duration string } type userSettingsData struct { @@ -86,6 +101,31 @@ type jobsPageData struct { Runs []models.CommandRun } +type updatesPageData struct { + TotalUpdates int64 + NodesWithUpdates int + ScannedNodes int + AutoUpdateNodes int + GlobalWindowStart string + GlobalWindowEnd string + GlobalUpdateDays string + Groups []updatesGroup + Ungrouped []models.Node +} + +type updatesGroup struct { + ID int64 + Name string + Nodes []models.Node + UpdatesAvailable int64 + NodesWithUpdates int + ScanEnabled bool + AutoUpdate bool + WindowStart string + WindowEnd string + UpdateDays string +} + type uptimePageData struct { Summary uptimeSummary Chart uptimeChartData @@ -401,6 +441,7 @@ func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) { SSHPort: 22, SSHUsername: username, SSHPassword: password, + UpdatesScanEnabled: true, AutoUpdatesEnabled: r.FormValue("auto_updates_enabled") == "on", Notes: notes, } @@ -423,6 +464,83 @@ func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } +func (h *Handler) UpdateNode(w http.ResponseWriter, r *http.Request) { + nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID) + if err != nil { + http.NotFound(w, r) + return + } + + ip := strings.TrimSpace(r.FormValue("ip_address")) + name := strings.TrimSpace(r.FormValue("name")) + username := strings.TrimSpace(r.FormValue("ssh_username")) + password := r.FormValue("ssh_password") + + if name == "" { + name = ip + } + if ip == "" || username == "" { + http.Error(w, "ip address and username are required", http.StatusBadRequest) + return + } + + var groupID *int64 + if raw := strings.TrimSpace(r.FormValue("group_id")); raw != "" { + parsed, parseErr := strconv.ParseInt(raw, 10, 64) + if parseErr == nil { + groupID = &parsed + } + } + + oldIP := node.IPAddress + oldUsername := node.SSHUsername + oldPassword := node.SSHPassword + node.GroupID = groupID + node.Tag = strings.TrimSpace(r.FormValue("tag")) + node.Name = name + node.IPAddress = ip + if node.Hostname == "" || node.Hostname == oldIP { + node.Hostname = ip + } + node.SSHUsername = username + if strings.TrimSpace(password) != "" { + node.SSHPassword = password + } + node.Notes = strings.TrimSpace(r.FormValue("notes")) + node.AutoUpdatesEnabled = r.FormValue("auto_updates_enabled") == "on" + node.UpdatesScanEnabled = r.FormValue("updates_scan_enabled") == "on" + + if err := h.nodes.SaveNode(r.Context(), node); err != nil { + http.Error(w, "failed to update node", http.StatusInternalServerError) + return + } + if err := h.nodes.EnsureUptimeMonitorForNode(r.Context(), node); err != nil { + http.Error(w, "failed to sync node monitor", http.StatusInternalServerError) + return + } + + if node.SSHUsername != "" && node.SSHPassword != "" { + credentialsChanged := node.IPAddress != oldIP || oldUsername != username || (strings.TrimSpace(password) != "" && password != oldPassword) + if credentialsChanged || strings.TrimSpace(node.PackageManager) == "" || strings.EqualFold(strings.TrimSpace(node.Distro), "linux") { + _, _ = h.nodes.RefreshNodeInventory(r.Context(), node) + } + _, _ = h.nodes.RefreshNodeStats(r.Context(), node) + } + + http.Redirect(w, r, "/nodes/"+strconv.FormatInt(nodeID, 10), http.StatusSeeOther) +} + func (h *Handler) NodeOverview(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64) @@ -445,9 +563,14 @@ func (h *Handler) NodeOverview(w http.ResponseWriter, r *http.Request) { } runs := h.listRuns(r.Context(), nodeID) + selectedGroupID := int64(0) + if node.GroupID != nil { + selectedGroupID = *node.GroupID + } h.render(w, r, "node", node.Name, nodePageData{ - Node: node, - CommandRuns: runs, + Node: node, + CommandRuns: runs, + SelectedGroupID: selectedGroupID, }, user) } @@ -666,7 +789,24 @@ func (h *Handler) NodeQuickCommand(w http.ResponseWriter, r *http.Request) { func (h *Handler) GroupsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID) - h.render(w, r, "groups", "VM Groups", map[string]any{"Nodes": nodes}, user) + groups, _ := h.repo.ListGroups(r.Context(), h.org.ID) + groupCounts := make(map[int64]int, len(groups)) + for _, node := range nodes { + if node.GroupID == nil { + continue + } + groupCounts[*node.GroupID]++ + } + + summaries := make([]groupSummary, 0, len(groups)) + for _, group := range groups { + summaries = append(summaries, groupSummary{ + Group: group, + VMCount: groupCounts[group.ID], + }) + } + + h.render(w, r, "groups", "VM Groups", groupsPageData{Groups: summaries}, user) } func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { @@ -686,6 +826,7 @@ func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { Name: name, Description: strings.TrimSpace(r.FormValue("description")), ColorToken: "primary", + Icon: defaultIfEmpty(strings.TrimSpace(r.FormValue("icon")), "ti ti-stack-2"), } if err := h.repo.CreateGroup(r.Context(), group); err != nil { http.Error(w, "failed to create group", http.StatusInternalServerError) @@ -695,6 +836,41 @@ func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/groups", http.StatusSeeOther) } +func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) { + groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "group name required", http.StatusBadRequest) + return + } + + group := &models.VMGroup{ + ID: groupID, + OrganizationID: h.org.ID, + Name: name, + Description: strings.TrimSpace(r.FormValue("description")), + ColorToken: "primary", + Icon: defaultIfEmpty(strings.TrimSpace(r.FormValue("icon")), "ti ti-stack-2"), + } + + if err := h.repo.UpdateGroup(r.Context(), group); err != nil { + http.Error(w, "failed to update group", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/groups", http.StatusSeeOther) +} + func (h *Handler) AutomationsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID) @@ -790,16 +966,44 @@ func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) { func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) - nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID) - groups, _ := h.repo.ListGroups(r.Context(), h.org.ID) - jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID) - userCount, _ := h.repo.CountUsers(r.Context()) - h.render(w, r, "settings", "Settings", settingsData{ - NodeCount: len(nodes), - GroupCount: len(groups), - JobCount: len(jobs), - UserCount: userCount, - Logs: h.settingsLogs(r.Context()), + page := 1 + if rawPage := strings.TrimSpace(r.URL.Query().Get("page")); rawPage != "" { + if parsedPage, err := strconv.Atoi(rawPage); err == nil && parsedPage > 0 { + page = parsedPage + } + } + + logs := h.settingsLogs(r.Context()) + const pageSize = 25 + totalPages := len(logs) / pageSize + if len(logs)%pageSize != 0 { + totalPages++ + } + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + start := (page - 1) * pageSize + end := start + pageSize + if start > len(logs) { + start = len(logs) + } + if end > len(logs) { + end = len(logs) + } + + h.render(w, r, "settings", "Logs", settingsData{ + Logs: logs[start:end], + Page: page, + TotalPages: totalPages, + HasPrev: page > 1, + HasNext: page < totalPages, + PrevPage: page - 1, + NextPage: page + 1, + CurrentPath: r.URL.Path, }, user) } @@ -832,6 +1036,11 @@ func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) { h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user) } +func (h *Handler) UpdatesPage(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + h.render(w, r, "updates", "Updates", h.updatesPageData(r.Context()), user) +} + func (h *Handler) RunUptimeChecks(w http.ResponseWriter, r *http.Request) { if err := h.nodes.EnsureUptimeMonitors(r.Context(), h.org.ID); err != nil { http.Error(w, "failed to sync monitors", http.StatusInternalServerError) @@ -868,6 +1077,142 @@ func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings", http.StatusSeeOther) } +func (h *Handler) ScanAllUpdates(w http.ResponseWriter, r *http.Request) { + _ = h.nodes.ScanAllNodeUpdates(r.Context(), h.org.ID) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) ApplyAllUpdates(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + var userID *int64 + if user != nil { + userID = &user.ID + } + _ = h.nodes.ApplyAllNodeUpdates(r.Context(), h.org.ID, userID) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) UpdateGlobalUpdateWindow(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + windowStart := normalizeClockValue(r.FormValue("auto_update_window_start")) + windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end")) + updateDays := joinWeekdaySelection(r.Form["auto_update_days"]) + if err := h.repo.UpdateOrganizationAutoUpdateWindow(r.Context(), windowStart, windowEnd, updateDays); err != nil { + http.Error(w, "failed to update global schedule", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) UpdateNodePolicy(w http.ResponseWriter, r *http.Request) { + nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + scanEnabled := r.FormValue("updates_scan_enabled") == "on" + autoUpdate := r.FormValue("auto_updates_enabled") == "on" + windowStart := normalizeClockValue(r.FormValue("auto_update_window_start")) + windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end")) + updateDays := joinWeekdaySelection(r.Form["auto_update_days"]) + if err := h.nodes.UpdateNodeUpdatePolicy(r.Context(), h.org.ID, nodeID, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays); err != nil { + http.Error(w, "failed to update policy", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) ScanNodeUpdates(w http.ResponseWriter, r *http.Request) { + nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID) + if err != nil { + http.NotFound(w, r) + return + } + _, _, _ = h.nodes.RefreshNodeUpdates(r.Context(), node) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) ApplyNodeUpdates(w http.ResponseWriter, r *http.Request) { + nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID) + if err != nil { + http.NotFound(w, r) + return + } + user := localmiddleware.CurrentUser(r) + var userID *int64 + if user != nil { + userID = &user.ID + } + _, _ = h.nodes.ApplyNodeUpdates(r.Context(), node, userID) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) UpdateGroupPolicy(w http.ResponseWriter, r *http.Request) { + groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + scanEnabled := r.FormValue("updates_scan_enabled") == "on" + autoUpdate := r.FormValue("auto_updates_enabled") == "on" + windowStart := normalizeClockValue(r.FormValue("auto_update_window_start")) + windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end")) + updateDays := joinWeekdaySelection(r.Form["auto_update_days"]) + if err := h.nodes.UpdateGroupUpdatePolicy(r.Context(), h.org.ID, groupID, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays); err != nil { + http.Error(w, "failed to update policy", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) ScanGroupUpdates(w http.ResponseWriter, r *http.Request) { + groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + _ = h.nodes.ScanGroupNodeUpdates(r.Context(), h.org.ID, groupID) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + +func (h *Handler) ApplyGroupUpdates(w http.ResponseWriter, r *http.Request) { + groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + user := localmiddleware.CurrentUser(r) + var userID *int64 + if user != nil { + userID = &user.ID + } + _ = h.nodes.ApplyGroupNodeUpdates(r.Context(), h.org.ID, groupID, userID) + http.Redirect(w, r, "/updates", http.StatusSeeOther) +} + func (h *Handler) UpdateUserTheme(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) if user == nil { @@ -1015,6 +1360,11 @@ func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title str func (h *Handler) dashboardData(ctx context.Context) dashboardData { nodes, _ := h.nodes.ListNodes(ctx, h.org.ID) + vmGroups, _ := h.repo.ListGroups(ctx, h.org.ID) + groupIcons := make(map[string]string, len(vmGroups)) + for _, group := range vmGroups { + groupIcons[group.Name] = group.Icon + } grouped := make(map[string][]models.Node) var ungrouped []models.Node for _, node := range nodes { @@ -1035,6 +1385,7 @@ func (h *Handler) dashboardData(ctx context.Context) dashboardData { for _, name := range groupNames { groups = append(groups, dashboardNodeGroup{ Name: name, + Icon: defaultIfEmpty(groupIcons[name], "ti ti-stack-2"), Nodes: grouped[name], }) } @@ -1045,6 +1396,89 @@ func (h *Handler) dashboardData(ctx context.Context) dashboardData { } } +func (h *Handler) updatesPageData(ctx context.Context) updatesPageData { + nodes, _ := h.nodes.ListNodes(ctx, h.org.ID) + grouped := make(map[string][]models.Node) + groupIDs := make(map[string]int64) + var ungrouped []models.Node + var totalUpdates int64 + var nodesWithUpdates int + var scannedNodes int + var autoNodes int + + for _, node := range nodes { + totalUpdates += node.UpdatesAvailable + if node.UpdatesAvailable > 0 { + nodesWithUpdates++ + } + if node.UpdatesLastChecked != nil { + scannedNodes++ + } + if node.AutoUpdatesEnabled { + autoNodes++ + } + if strings.TrimSpace(node.GroupName) == "" || node.GroupID == nil { + ungrouped = append(ungrouped, node) + continue + } + grouped[node.GroupName] = append(grouped[node.GroupName], node) + groupIDs[node.GroupName] = *node.GroupID + } + + var groupNames []string + for name := range grouped { + groupNames = append(groupNames, name) + } + sort.Strings(groupNames) + + var groups []updatesGroup + for _, name := range groupNames { + groupNodes := grouped[name] + group := updatesGroup{ + ID: groupIDs[name], + Name: name, + Nodes: groupNodes, + ScanEnabled: true, + AutoUpdate: true, + } + if len(groupNodes) > 0 { + group.WindowStart = groupNodes[0].AutoUpdateWindowStart + group.WindowEnd = groupNodes[0].AutoUpdateWindowEnd + group.UpdateDays = groupNodes[0].AutoUpdateDays + } + for _, node := range groupNodes { + group.UpdatesAvailable += node.UpdatesAvailable + if node.UpdatesAvailable > 0 { + group.NodesWithUpdates++ + } + group.ScanEnabled = group.ScanEnabled && node.UpdatesScanEnabled + group.AutoUpdate = group.AutoUpdate && node.AutoUpdatesEnabled + if group.WindowStart != node.AutoUpdateWindowStart { + group.WindowStart = "" + } + if group.WindowEnd != node.AutoUpdateWindowEnd { + group.WindowEnd = "" + } + if group.UpdateDays != node.AutoUpdateDays { + group.UpdateDays = "" + } + } + groups = append(groups, group) + } + + return updatesPageData{ + TotalUpdates: totalUpdates, + NodesWithUpdates: nodesWithUpdates, + ScannedNodes: scannedNodes, + AutoUpdateNodes: autoNodes, + GlobalWindowStart: h.org.AutoUpdateWindowStart, + GlobalWindowEnd: h.org.AutoUpdateWindowEnd, + GlobalUpdateDays: h.org.AutoUpdateDays, + Groups: groups, + Ungrouped: ungrouped, + } +} + func (h *Handler) uptimePageData(ctx context.Context) uptimePageData { _ = h.nodes.EnsureUptimeMonitors(ctx, h.org.ID) @@ -1396,6 +1830,7 @@ func (h *Handler) settingsLogs(ctx context.Context) []settingsCommandLog { Status: run.Status, CommandText: commandText, Output: strings.TrimSpace(run.Output), + Duration: run.DurationText, }) } @@ -1440,6 +1875,40 @@ func buildSchedule(triggerType, kind, hour, minute, weekday, intervalValue, inte } } +func normalizeClockValue(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + var hour int + var minute int + if _, err := fmt.Sscanf(value, "%d:%d", &hour, &minute); err != nil { + return "" + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return "" + } + return fmt.Sprintf("%02d:%02d", hour, minute) +} + +func joinWeekdaySelection(values []string) string { + allowed := []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} + present := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed != "" { + present[trimmed] = struct{}{} + } + } + var selected []string + for _, day := range allowed { + if _, ok := present[day]; ok { + selected = append(selected, day) + } + } + return strings.Join(selected, ",") +} + func defaultIfEmpty(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback diff --git a/internal/models/models.go b/internal/models/models.go index 3333159..726d38f 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,11 +11,14 @@ const ( ) type Organization struct { - ID int64 - Name string - Theme string - ThemeMode string - CreatedAt time.Time + ID int64 + Name string + Theme string + ThemeMode string + AutoUpdateWindowStart string + AutoUpdateWindowEnd string + AutoUpdateDays string + CreatedAt time.Time } type User struct { @@ -37,42 +40,58 @@ type VMGroup struct { Name string Description string ColorToken string + Icon string CreatedAt time.Time } type Node struct { - ID int64 - OrganizationID int64 - GroupID *int64 - GroupName string - Tag string - Name string - Distro string - Hostname string - IPAddress string - MACAddress string - SSHPort int - SSHUsername string - SSHPassword string - PackageManager string - Architecture string - HostModel string - KernelVersion string - CPUModel string - GPUModel string - DefaultShell string - PackageCount int64 - MemoryTotalMB int64 - DiskTotalGB int64 - CPUUsage float64 - RAMUsage float64 - DiskUsage float64 - UptimeSeconds int64 - LastSeenAt *time.Time - AutoUpdatesEnabled bool - Notes string - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + OrganizationID int64 + GroupID *int64 + GroupName string + Tag string + Name string + Distro string + Hostname string + IPAddress string + MACAddress string + SSHPort int + SSHUsername string + SSHPassword string + PackageManager string + Architecture string + HostModel string + KernelVersion string + CPUModel string + GPUModel string + DefaultShell string + PackageCount int64 + MemoryTotalMB int64 + DiskTotalGB int64 + CPUUsage float64 + RAMUsage float64 + DiskUsage float64 + UptimeSeconds int64 + LastSeenAt *time.Time + UpdatesScanEnabled bool + AutoUpdatesEnabled bool + UpdatesAvailable int64 + UpdatesDetails string + AutoUpdateWindowStart string + AutoUpdateWindowEnd string + AutoUpdateDays string + UpdatesLastChecked *time.Time + UpdatesLastError string + Notes string + CreatedAt time.Time + UpdatedAt time.Time +} + +type NodeUpdatePackage struct { + Name string + Current string + Available string + Architecture string } type AutomationJob struct { diff --git a/internal/services/node.go b/internal/services/node.go index c2095c7..b612430 100644 --- a/internal/services/node.go +++ b/internal/services/node.go @@ -3,6 +3,7 @@ package services import ( "context" "database/sql" + "encoding/json" "fmt" "net" "regexp" @@ -28,7 +29,7 @@ func (s *NodeService) ListNodes(ctx context.Context, orgID int64) ([]models.Node SELECT n.id, n.organization_id, n.group_id, COALESCE(g.name, ''), n.tag, n.name, n.distro, n.hostname, n.ip_address, n.mac_address, n.ssh_port, n.ssh_username, n.ssh_password, n.package_manager, n.architecture, n.host_model, n.kernel_version, n.cpu_model, n.gpu_model, n.default_shell, n.package_count, n.memory_total_mb, n.disk_total_gb, n.cpu_usage, n.ram_usage, n.disk_usage, - n.uptime_seconds, n.last_seen_at, n.auto_updates_enabled, n.notes, n.created_at, n.updated_at + n.uptime_seconds, n.last_seen_at, n.updates_scan_enabled, n.auto_updates_enabled, n.updates_available, n.updates_details, n.auto_update_window_start, n.auto_update_window_end, n.auto_update_days, n.updates_last_checked_at, n.updates_last_error, n.notes, n.created_at, n.updated_at FROM nodes n LEFT JOIN vm_groups g ON g.id = n.group_id WHERE n.organization_id = ? @@ -47,7 +48,7 @@ func (s *NodeService) ListNodesByGroup(ctx context.Context, orgID, groupID int64 SELECT n.id, n.organization_id, n.group_id, COALESCE(g.name, ''), n.tag, n.name, n.distro, n.hostname, n.ip_address, n.mac_address, n.ssh_port, n.ssh_username, n.ssh_password, n.package_manager, n.architecture, n.host_model, n.kernel_version, n.cpu_model, n.gpu_model, n.default_shell, n.package_count, n.memory_total_mb, n.disk_total_gb, n.cpu_usage, n.ram_usage, n.disk_usage, - n.uptime_seconds, n.last_seen_at, n.auto_updates_enabled, n.notes, n.created_at, n.updated_at + n.uptime_seconds, n.last_seen_at, n.updates_scan_enabled, n.auto_updates_enabled, n.updates_available, n.updates_details, n.auto_update_window_start, n.auto_update_window_end, n.auto_update_days, n.updates_last_checked_at, n.updates_last_error, n.notes, n.created_at, n.updated_at FROM nodes n LEFT JOIN vm_groups g ON g.id = n.group_id WHERE n.organization_id = ? AND n.group_id = ? @@ -66,7 +67,7 @@ func (s *NodeService) ListNodesByTag(ctx context.Context, orgID int64, tag strin SELECT n.id, n.organization_id, n.group_id, COALESCE(g.name, ''), n.tag, n.name, n.distro, n.hostname, n.ip_address, n.mac_address, n.ssh_port, n.ssh_username, n.ssh_password, n.package_manager, n.architecture, n.host_model, n.kernel_version, n.cpu_model, n.gpu_model, n.default_shell, n.package_count, n.memory_total_mb, n.disk_total_gb, n.cpu_usage, n.ram_usage, n.disk_usage, - n.uptime_seconds, n.last_seen_at, n.auto_updates_enabled, n.notes, n.created_at, n.updated_at + n.uptime_seconds, n.last_seen_at, n.updates_scan_enabled, n.auto_updates_enabled, n.updates_available, n.updates_details, n.auto_update_window_start, n.auto_update_window_end, n.auto_update_days, n.updates_last_checked_at, n.updates_last_error, n.notes, n.created_at, n.updated_at FROM nodes n LEFT JOIN vm_groups g ON g.id = n.group_id WHERE n.organization_id = ? AND n.tag = ? @@ -89,7 +90,7 @@ func (s *NodeService) scanNodes(rows *sql.Rows) ([]models.Node, error) { &node.IPAddress, &node.MACAddress, &node.SSHPort, &node.SSHUsername, &node.SSHPassword, &node.PackageManager, &node.Architecture, &node.HostModel, &node.KernelVersion, &node.CPUModel, &node.GPUModel, &node.DefaultShell, &node.PackageCount, &node.MemoryTotalMB, &node.DiskTotalGB, &node.CPUUsage, &node.RAMUsage, &node.DiskUsage, &node.UptimeSeconds, &node.LastSeenAt, - &node.AutoUpdatesEnabled, &node.Notes, &node.CreatedAt, &node.UpdatedAt, + &node.UpdatesScanEnabled, &node.AutoUpdatesEnabled, &node.UpdatesAvailable, &node.UpdatesDetails, &node.AutoUpdateWindowStart, &node.AutoUpdateWindowEnd, &node.AutoUpdateDays, &node.UpdatesLastChecked, &node.UpdatesLastError, &node.Notes, &node.CreatedAt, &node.UpdatedAt, ); err != nil { return nil, err } @@ -110,7 +111,7 @@ func (s *NodeService) GetNode(ctx context.Context, orgID, nodeID int64) (*models SELECT n.id, n.organization_id, n.group_id, COALESCE(g.name, ''), n.tag, n.name, n.distro, n.hostname, n.ip_address, n.mac_address, n.ssh_port, n.ssh_username, n.ssh_password, n.package_manager, n.architecture, n.host_model, n.kernel_version, n.cpu_model, n.gpu_model, n.default_shell, n.package_count, n.memory_total_mb, n.disk_total_gb, n.cpu_usage, n.ram_usage, n.disk_usage, - n.uptime_seconds, n.last_seen_at, n.auto_updates_enabled, n.notes, n.created_at, n.updated_at + n.uptime_seconds, n.last_seen_at, n.updates_scan_enabled, n.auto_updates_enabled, n.updates_available, n.updates_details, n.auto_update_window_start, n.auto_update_window_end, n.auto_update_days, n.updates_last_checked_at, n.updates_last_error, n.notes, n.created_at, n.updated_at FROM nodes n LEFT JOIN vm_groups g ON g.id = n.group_id WHERE n.organization_id = ? AND n.id = ? @@ -119,7 +120,7 @@ func (s *NodeService) GetNode(ctx context.Context, orgID, nodeID int64) (*models &node.IPAddress, &node.MACAddress, &node.SSHPort, &node.SSHUsername, &node.SSHPassword, &node.PackageManager, &node.Architecture, &node.HostModel, &node.KernelVersion, &node.CPUModel, &node.GPUModel, &node.DefaultShell, &node.PackageCount, &node.MemoryTotalMB, &node.DiskTotalGB, &node.CPUUsage, &node.RAMUsage, &node.DiskUsage, &node.UptimeSeconds, &node.LastSeenAt, - &node.AutoUpdatesEnabled, &node.Notes, &node.CreatedAt, &node.UpdatedAt, + &node.UpdatesScanEnabled, &node.AutoUpdatesEnabled, &node.UpdatesAvailable, &node.UpdatesDetails, &node.AutoUpdateWindowStart, &node.AutoUpdateWindowEnd, &node.AutoUpdateDays, &node.UpdatesLastChecked, &node.UpdatesLastError, &node.Notes, &node.CreatedAt, &node.UpdatedAt, ) if err != nil { return nil, err @@ -149,10 +150,10 @@ func (s *NodeService) SaveNode(ctx context.Context, node *models.Node) error { INSERT INTO nodes ( organization_id, group_id, tag, name, distro, hostname, ip_address, mac_address, ssh_port, ssh_username, ssh_password, package_manager, architecture, host_model, kernel_version, cpu_model, gpu_model, default_shell, package_count, memory_total_mb, disk_total_gb, - auto_updates_enabled, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + updates_scan_enabled, auto_updates_enabled, updates_available, updates_details, auto_update_window_start, auto_update_window_end, auto_update_days, updates_last_checked_at, updates_last_error, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, node.OrganizationID, node.GroupID, node.Tag, node.Name, node.Distro, node.Hostname, node.IPAddress, - node.MACAddress, node.SSHPort, node.SSHUsername, encryptedPassword, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.AutoUpdatesEnabled, node.Notes) + node.MACAddress, node.SSHPort, node.SSHUsername, encryptedPassword, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.UpdatesScanEnabled, node.AutoUpdatesEnabled, node.UpdatesAvailable, node.UpdatesDetails, node.AutoUpdateWindowStart, node.AutoUpdateWindowEnd, node.AutoUpdateDays, node.UpdatesLastChecked, node.UpdatesLastError, node.Notes) if err != nil { return err } @@ -164,11 +165,11 @@ func (s *NodeService) SaveNode(ctx context.Context, node *models.Node) error { UPDATE nodes SET group_id = ?, tag = ?, name = ?, distro = ?, hostname = ?, ip_address = ?, mac_address = ?, ssh_port = ?, ssh_username = ?, ssh_password = ?, package_manager = ?, architecture = ?, host_model = ?, kernel_version = ?, cpu_model = ?, gpu_model = ?, default_shell = ?, package_count = ?, memory_total_mb = ?, disk_total_gb = ?, - auto_updates_enabled = ?, notes = ?, + updates_scan_enabled = ?, auto_updates_enabled = ?, updates_available = ?, updates_details = ?, auto_update_window_start = ?, auto_update_window_end = ?, auto_update_days = ?, updates_last_checked_at = ?, updates_last_error = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND organization_id = ? `, node.GroupID, node.Tag, node.Name, node.Distro, node.Hostname, node.IPAddress, node.MACAddress, - node.SSHPort, node.SSHUsername, encryptedPassword, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.AutoUpdatesEnabled, node.Notes, + node.SSHPort, node.SSHUsername, encryptedPassword, node.PackageManager, node.Architecture, node.HostModel, node.KernelVersion, node.CPUModel, node.GPUModel, node.DefaultShell, node.PackageCount, node.MemoryTotalMB, node.DiskTotalGB, node.UpdatesScanEnabled, node.AutoUpdatesEnabled, node.UpdatesAvailable, node.UpdatesDetails, node.AutoUpdateWindowStart, node.AutoUpdateWindowEnd, node.AutoUpdateDays, node.UpdatesLastChecked, node.UpdatesLastError, node.Notes, node.ID, node.OrganizationID) return err } @@ -215,6 +216,39 @@ func (s *NodeService) DeleteNode(ctx context.Context, orgID, nodeID int64) error } defer tx.Rollback() + rows, err := tx.QueryContext(ctx, ` + SELECT id + FROM uptime_monitors + WHERE organization_id = ? AND node_id = ? + `, orgID, nodeID) + if err != nil { + return err + } + defer rows.Close() + + var monitorIDs []int64 + for rows.Next() { + var monitorID int64 + if err := rows.Scan(&monitorID); err != nil { + return err + } + monitorIDs = append(monitorIDs, monitorID) + } + if err := rows.Err(); err != nil { + return err + } + + for _, monitorID := range monitorIDs { + if _, err := tx.ExecContext(ctx, `DELETE FROM uptime_checks WHERE monitor_id = ?`, monitorID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM uptime_incidents WHERE monitor_id = ?`, monitorID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM uptime_monitors WHERE organization_id = ? AND node_id = ?`, orgID, nodeID); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `DELETE FROM command_runs WHERE node_id = ?`, nodeID); err != nil { return err } @@ -236,6 +270,222 @@ func (s *NodeService) DeleteNode(ctx context.Context, orgID, nodeID int64) error return tx.Commit() } +func (s *NodeService) UpdateNodeUpdatePolicy(ctx context.Context, orgID, nodeID int64, scanEnabled, autoUpdate bool, windowStart, windowEnd, updateDays string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE nodes + SET updates_scan_enabled = ?, auto_updates_enabled = ?, auto_update_window_start = ?, auto_update_window_end = ?, auto_update_days = ?, updated_at = CURRENT_TIMESTAMP + WHERE organization_id = ? AND id = ? + `, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays, orgID, nodeID) + return err +} + +func (s *NodeService) UpdateGroupUpdatePolicy(ctx context.Context, orgID, groupID int64, scanEnabled, autoUpdate bool, windowStart, windowEnd, updateDays string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE nodes + SET updates_scan_enabled = ?, auto_updates_enabled = ?, auto_update_window_start = ?, auto_update_window_end = ?, auto_update_days = ?, updated_at = CURRENT_TIMESTAMP + WHERE organization_id = ? AND group_id = ? + `, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays, orgID, groupID) + return err +} + +func (s *NodeService) RefreshNodeUpdates(ctx context.Context, node *models.Node) (int64, string, error) { + command := updateScanCommand(node.PackageManager) + if strings.TrimSpace(command) == "" { + node.UpdatesAvailable = 0 + node.UpdatesDetails = "" + node.UpdatesLastError = "Unsupported package manager" + now := time.Now() + node.UpdatesLastChecked = &now + _, _ = s.db.ExecContext(ctx, ` + UPDATE nodes + SET updates_available = 0, updates_details = '', updates_last_checked_at = ?, updates_last_error = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND organization_id = ? + `, now, node.UpdatesLastError, node.ID, node.OrganizationID) + return 0, "", fmt.Errorf("unsupported package manager") + } + + startedAt := time.Now() + output, err := s.RunSSHCommand(ctx, node, command) + finishedAt := time.Now() + status := "completed" + if err != nil { + status = "failed" + output = strings.TrimSpace(output + "\n" + err.Error()) + } + + count, packages, parseErr := parseUpdateScan(output) + lastError := "" + if err != nil { + lastError = err.Error() + } else if parseErr != nil { + lastError = parseErr.Error() + status = "failed" + } + if err == nil && parseErr == nil { + node.UpdatesAvailable = count + node.UpdatesDetails = mustMarshalUpdatePackages(packages) + } else { + node.UpdatesDetails = "" + } + node.UpdatesLastError = lastError + node.UpdatesLastChecked = &finishedAt + + _, dbErr := s.db.ExecContext(ctx, ` + UPDATE nodes + SET updates_available = ?, updates_details = ?, updates_last_checked_at = ?, updates_last_error = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND organization_id = ? + `, node.UpdatesAvailable, node.UpdatesDetails, finishedAt, node.UpdatesLastError, node.ID, node.OrganizationID) + if dbErr != nil { + return node.UpdatesAvailable, output, dbErr + } + + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "scan-updates", + CommandText: sanitizeCommand(command), + Status: status, + Output: output, + StartedAt: &startedAt, + FinishedAt: &finishedAt, + }) + + if err != nil { + return node.UpdatesAvailable, output, err + } + if parseErr != nil { + return node.UpdatesAvailable, output, parseErr + } + return count, output, nil +} + +func (s *NodeService) ApplyNodeUpdates(ctx context.Context, node *models.Node, triggeredBy *int64) (string, error) { + baseCommand := updateApplyCommand(node.PackageManager) + if strings.TrimSpace(baseCommand) == "" { + return "", fmt.Errorf("unsupported package manager") + } + command := sudoWrappedCommand(baseCommand, node.SSHPassword) + + startedAt := time.Now() + output, err := s.RunSSHCommand(ctx, node, command) + finishedAt := time.Now() + status := "completed" + if err != nil { + status = "failed" + output = strings.TrimSpace(output + "\n" + err.Error()) + } + + s.logCommandRun(ctx, commandRunParams{ + NodeID: node.ID, + Action: "apply-updates", + CommandText: sanitizeCommand(baseCommand), + Status: status, + Output: output, + TriggeredBy: triggeredBy, + StartedAt: &startedAt, + FinishedAt: &finishedAt, + }) + + if err != nil { + return output, err + } + + _, _, _ = s.RefreshNodeUpdates(ctx, node) + return output, nil +} + +func (s *NodeService) ScanAllNodeUpdates(ctx context.Context, orgID int64) error { + nodes, err := s.ListNodes(ctx, orgID) + if err != nil { + return err + } + orgWindow, _ := s.organizationAutoUpdateWindow(ctx, orgID) + for i := range nodes { + if !nodes[i].UpdatesScanEnabled || nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" { + continue + } + _, _, _ = s.RefreshNodeUpdates(ctx, &nodes[i]) + if nodes[i].AutoUpdatesEnabled && nodes[i].UpdatesAvailable > 0 && autoUpdateWindowOpen(&nodes[i], orgWindow, time.Now()) { + _, _ = s.ApplyNodeUpdates(ctx, &nodes[i], nil) + } + } + return nil +} + +func (s *NodeService) RefreshAllNodeInventory(ctx context.Context, orgID int64) error { + nodes, err := s.ListNodes(ctx, orgID) + if err != nil { + return err + } + for i := range nodes { + if nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" { + continue + } + _, _ = s.RefreshNodeInventory(ctx, &nodes[i]) + } + return nil +} + +func (s *NodeService) ScanGroupNodeUpdates(ctx context.Context, orgID, groupID int64) error { + nodes, err := s.ListNodesByGroup(ctx, orgID, groupID) + if err != nil { + return err + } + for i := range nodes { + if !nodes[i].UpdatesScanEnabled || nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" { + continue + } + _, _, _ = s.RefreshNodeUpdates(ctx, &nodes[i]) + } + return nil +} + +func (s *NodeService) ApplyAllNodeUpdates(ctx context.Context, orgID int64, triggeredBy *int64) error { + nodes, err := s.ListNodes(ctx, orgID) + if err != nil { + return err + } + for i := range nodes { + if nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" || nodes[i].UpdatesAvailable <= 0 { + continue + } + _, _ = s.ApplyNodeUpdates(ctx, &nodes[i], triggeredBy) + } + return nil +} + +func (s *NodeService) ApplyEligibleAutoUpdates(ctx context.Context, orgID int64) error { + nodes, err := s.ListNodes(ctx, orgID) + if err != nil { + return err + } + orgWindow, _ := s.organizationAutoUpdateWindow(ctx, orgID) + now := time.Now() + for i := range nodes { + if !nodes[i].AutoUpdatesEnabled || nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" || nodes[i].UpdatesAvailable <= 0 { + continue + } + if !autoUpdateWindowOpen(&nodes[i], orgWindow, now) { + continue + } + _, _ = s.ApplyNodeUpdates(ctx, &nodes[i], nil) + } + return nil +} + +func (s *NodeService) ApplyGroupNodeUpdates(ctx context.Context, orgID, groupID int64, triggeredBy *int64) error { + nodes, err := s.ListNodesByGroup(ctx, orgID, groupID) + if err != nil { + return err + } + for i := range nodes { + if nodes[i].SSHUsername == "" || nodes[i].SSHPassword == "" || nodes[i].UpdatesAvailable <= 0 { + continue + } + _, _ = s.ApplyNodeUpdates(ctx, &nodes[i], triggeredBy) + } + return nil +} + func (s *NodeService) RefreshNodeStats(ctx context.Context, node *models.Node) (string, error) { const statsScript = ` read _ user nice system idle iowait irq softirq steal _ _ < /proc/stat @@ -732,48 +982,70 @@ func (s *NodeService) ListRecentUptimeIncidents(ctx context.Context, orgID int64 func (s *NodeService) UptimePeriodSummary(ctx context.Context, orgID int64, since *time.Time) (models.UptimePeriodSummary, error) { var summary models.UptimePeriodSummary - args := []any{orgID} - filter := "" if since != nil { - filter = " AND c.checked_at >= ?" - args = append(args, *since) - } - - if err := s.db.QueryRowContext(ctx, ` - SELECT - COUNT(*), - COALESCE(SUM(CASE WHEN c.status = 'up' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN c.status = 'down' THEN 1 ELSE 0 END), 0), - COALESCE(CAST(AVG(CASE WHEN c.status = 'up' THEN c.latency_ms END) AS INTEGER), 0), - COALESCE(SUM(CASE WHEN c.status = 'down' THEN m.interval_seconds ELSE 0 END), 0) - FROM uptime_checks c - INNER JOIN uptime_monitors m ON m.id = c.monitor_id - WHERE m.organization_id = ?`+filter, args...).Scan( - &summary.TotalChecks, &summary.UpChecks, &summary.DownChecks, &summary.AvgLatencyMS, &summary.DowntimeSeconds, - ); err != nil { - return summary, err - } - - incidentArgs := []any{orgID} - incidentFilter := "" - if since != nil { - incidentFilter = " AND i.started_at >= ?" - incidentArgs = append(incidentArgs, *since) + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN c.status = 'up' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN 1 ELSE 0 END), 0), + COALESCE(CAST(AVG(CASE WHEN c.status = 'up' THEN c.latency_ms END) AS INTEGER), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN m.interval_seconds ELSE 0 END), 0) + FROM uptime_checks c + INNER JOIN uptime_monitors m ON m.id = c.monitor_id + WHERE m.organization_id = ? AND c.checked_at >= ? + `, orgID, *since).Scan( + &summary.TotalChecks, &summary.UpChecks, &summary.DownChecks, &summary.AvgLatencyMS, &summary.DowntimeSeconds, + ); err != nil { + return summary, err + } + } else { + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN c.status = 'up' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN 1 ELSE 0 END), 0), + COALESCE(CAST(AVG(CASE WHEN c.status = 'up' THEN c.latency_ms END) AS INTEGER), 0), + COALESCE(SUM(CASE WHEN c.status = 'down' THEN m.interval_seconds ELSE 0 END), 0) + FROM uptime_checks c + INNER JOIN uptime_monitors m ON m.id = c.monitor_id + WHERE m.organization_id = ? + `, orgID).Scan( + &summary.TotalChecks, &summary.UpChecks, &summary.DownChecks, &summary.AvgLatencyMS, &summary.DowntimeSeconds, + ); err != nil { + return summary, err + } } var longest sql.NullInt64 var avg sql.NullFloat64 - if err := s.db.QueryRowContext(ctx, ` - SELECT - COUNT(*), - MAX(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)), - AVG(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)) - FROM uptime_incidents i - INNER JOIN uptime_monitors m ON m.id = i.monitor_id - WHERE m.organization_id = ?`+incidentFilter, incidentArgs...).Scan( - &summary.IncidentCount, &longest, &avg, - ); err != nil { - return summary, err + if since != nil { + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + MAX(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)), + AVG(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)) + FROM uptime_incidents i + INNER JOIN uptime_monitors m ON m.id = i.monitor_id + WHERE m.organization_id = ? AND i.started_at >= ? + `, orgID, *since).Scan( + &summary.IncidentCount, &longest, &avg, + ); err != nil { + return summary, err + } + } else { + if err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + MAX(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)), + AVG(CAST((strftime('%s', COALESCE(i.ended_at, CURRENT_TIMESTAMP)) - strftime('%s', i.started_at)) AS INTEGER)) + FROM uptime_incidents i + INNER JOIN uptime_monitors m ON m.id = i.monitor_id + WHERE m.organization_id = ? + `, orgID).Scan( + &summary.IncidentCount, &longest, &avg, + ); err != nil { + return summary, err + } } if longest.Valid { summary.LongestIncidentSeconds = longest.Int64 @@ -964,6 +1236,19 @@ func (s *SchedulerService) Start(ctx context.Context, orgID int64, refreshSpec s return err } + if _, err := s.cron.AddFunc("@daily", func() { + _ = s.nodeService.RefreshAllNodeInventory(context.Background(), orgID) + _ = s.nodeService.ScanAllNodeUpdates(context.Background(), orgID) + }); err != nil { + return err + } + + if _, err := s.cron.AddFunc("@every 15m", func() { + _ = s.nodeService.ApplyEligibleAutoUpdates(context.Background(), orgID) + }); err != nil { + return err + } + jobs, err := s.nodeService.ListAutomations(ctx, orgID) if err != nil { return err @@ -1122,6 +1407,190 @@ func sanitizeCommand(command string) string { return sanitized } +func updateScanCommand(packageManager string) string { + switch strings.ToLower(strings.TrimSpace(packageManager)) { + case "apt": + return `apt list --upgradable 2>/dev/null | awk 'BEGIN{count=0} NR > 1 && /\[upgradable from:/ { count++; pkg=$1; sub(/\/.*/, "", pkg); arch=""; split($1, parts, "/"); if (length(parts) > 1) arch=parts[2]; from=$0; sub(/^.*\[upgradable from: /, "", from); sub(/\].*$/, "", from); printf "PKG|%s|%s|%s|%s\n", pkg, from, $2, arch } END { printf "UPDATES=%d\n", count }'` + case "dnf": + return `tmp="$(mktemp)"; dnf check-update -q >"$tmp" 2>/dev/null; status=$?; awk -v status="$status" 'BEGIN{count=0} status == 100 && /^[[:alnum:]_.+-]+[[:space:]]+[[:alnum:]_.:+~^-]+[[:space:]]+/ { count++; pkg=$1; ver=$2; repo=$3; arch=""; if (match(pkg, /\.([^.]+)$/, m)) { arch=m[1]; sub(/\.[^.]+$/, "", pkg) } printf "PKG|%s||%s|%s\n", pkg, ver, arch } END { printf "UPDATES=%d\n", count }' "$tmp"; rm -f "$tmp"` + case "yum": + return `tmp="$(mktemp)"; yum check-update -q >"$tmp" 2>/dev/null; status=$?; awk -v status="$status" 'BEGIN{count=0} status == 100 && /^[[:alnum:]_.+-]+[[:space:]]+[[:alnum:]_.:+~^-]+[[:space:]]+/ { count++; pkg=$1; ver=$2; arch=""; if (match(pkg, /\.([^.]+)$/, m)) { arch=m[1]; sub(/\.[^.]+$/, "", pkg) } printf "PKG|%s||%s|%s\n", pkg, ver, arch } END { printf "UPDATES=%d\n", count }' "$tmp"; rm -f "$tmp"` + case "pacman": + return `if command -v checkupdates >/dev/null 2>&1; then checkupdates 2>/dev/null; else pacman -Qu 2>/dev/null; fi | awk 'BEGIN{count=0} NF >= 3 {count++; printf "PKG|%s|%s|%s|\n", $1, $2, $4} END { printf "UPDATES=%d\n", count }'` + case "zypper": + return `zypper list-updates 2>/dev/null | awk 'BEGIN{count=0} /^\|/ && $0 !~ /\| Repository / && $0 !~ /\| Status / && $0 !~ /\+-/ { split($0, parts, "|"); pkg=trim(parts[3]); cur=trim(parts[4]); avail=trim(parts[5]); arch=trim(parts[6]); if (pkg != "" && pkg != "Name") { count++; printf "PKG|%s|%s|%s|%s\n", pkg, cur, avail, arch } } END { printf "UPDATES=%d\n", count } function trim(value) { gsub(/^[ \t]+|[ \t]+$/, "", value); return value }'` + case "apk": + return `apk version -l '<' 2>/dev/null | awk 'BEGIN{count=0} NF {count++; pkg=$1; sub(/-[0-9].*$/, "", pkg); printf "PKG|%s||%s|\n", pkg, $0} END { printf "UPDATES=%d\n", count }'` + default: + return "" + } +} + +func updateApplyCommand(packageManager string) string { + switch strings.ToLower(strings.TrimSpace(packageManager)) { + case "apt": + return `if command -v sudo >/dev/null 2>&1; then sudo -n apt-get update && sudo -n DEBIAN_FRONTEND=noninteractive apt-get -y upgrade; else apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade; fi` + case "dnf": + return `if command -v sudo >/dev/null 2>&1; then sudo -n dnf -y upgrade --refresh; else dnf -y upgrade --refresh; fi` + case "yum": + return `if command -v sudo >/dev/null 2>&1; then sudo -n yum -y update; else yum -y update; fi` + case "pacman": + return `if command -v sudo >/dev/null 2>&1; then sudo -n pacman -Syu --noconfirm; else pacman -Syu --noconfirm; fi` + case "zypper": + return `if command -v sudo >/dev/null 2>&1; then sudo -n zypper --non-interactive update; else zypper --non-interactive update; fi` + case "apk": + return `if command -v sudo >/dev/null 2>&1; then sudo -n apk update && sudo -n apk upgrade; else apk update && apk upgrade; fi` + default: + return "" + } +} + +func sudoWrappedCommand(command, password string) string { + command = strings.TrimSpace(command) + if command == "" { + return "" + } + quotedCommand := shellSingleQuote(command) + if strings.TrimSpace(password) == "" { + return fmt.Sprintf("if command -v sudo >/dev/null 2>&1; then sudo -n sh -lc %s; else sh -lc %s; fi", quotedCommand, quotedCommand) + } + quotedPassword := shellSingleQuote(password) + return fmt.Sprintf("if command -v sudo >/dev/null 2>&1; then printf '%%s\\n' %s | sudo -S -p '' sh -lc %s; else sh -lc %s; fi", quotedPassword, quotedCommand, quotedCommand) +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func parseUpdateScan(output string) (int64, []models.NodeUpdatePackage, error) { + var packages []models.NodeUpdatePackage + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "PKG|") { + parts := strings.SplitN(line, "|", 5) + if len(parts) == 5 { + packages = append(packages, models.NodeUpdatePackage{ + Name: strings.TrimSpace(parts[1]), + Current: strings.TrimSpace(parts[2]), + Available: strings.TrimSpace(parts[3]), + Architecture: strings.TrimSpace(parts[4]), + }) + } + continue + } + if !strings.HasPrefix(line, "UPDATES=") { + continue + } + var count int64 + if _, err := fmt.Sscanf(line, "UPDATES=%d", &count); err != nil { + return 0, nil, err + } + return count, packages, nil + } + return 0, packages, fmt.Errorf("update count not found") +} + +func mustMarshalUpdatePackages(packages []models.NodeUpdatePackage) string { + if len(packages) == 0 { + return "" + } + raw, err := json.Marshal(packages) + if err != nil { + return "" + } + return string(raw) +} + +func autoUpdateWindowOpen(node *models.Node, org models.Organization, now time.Time) bool { + if node == nil { + return false + } + days := strings.TrimSpace(node.AutoUpdateDays) + start := strings.TrimSpace(node.AutoUpdateWindowStart) + end := strings.TrimSpace(node.AutoUpdateWindowEnd) + if days == "" { + days = strings.TrimSpace(org.AutoUpdateDays) + } + if start == "" { + start = strings.TrimSpace(org.AutoUpdateWindowStart) + } + if end == "" { + end = strings.TrimSpace(org.AutoUpdateWindowEnd) + } + if !updateDayAllowed(days, now.Weekday()) { + return false + } + startMinutes, hasStart := parseClockMinutes(start) + endMinutes, hasEnd := parseClockMinutes(end) + if !hasStart || !hasEnd { + return true + } + + currentMinutes := now.Hour()*60 + now.Minute() + if startMinutes == endMinutes { + return true + } + if startMinutes < endMinutes { + return currentMinutes >= startMinutes && currentMinutes <= endMinutes + } + return currentMinutes >= startMinutes || currentMinutes <= endMinutes +} + +func (s *NodeService) organizationAutoUpdateWindow(ctx context.Context, orgID int64) (models.Organization, error) { + var org models.Organization + err := s.db.QueryRowContext(ctx, ` + SELECT id, name, theme, theme_mode, auto_update_window_start, auto_update_window_end, auto_update_days, created_at + FROM organizations + WHERE id = ? + `, orgID).Scan(&org.ID, &org.Name, &org.Theme, &org.ThemeMode, &org.AutoUpdateWindowStart, &org.AutoUpdateWindowEnd, &org.AutoUpdateDays, &org.CreatedAt) + return org, err +} + +func updateDayAllowed(days string, weekday time.Weekday) bool { + selected := strings.TrimSpace(days) + if selected == "" { + return true + } + code := weekdayCode(weekday) + for _, item := range strings.Split(selected, ",") { + if strings.EqualFold(strings.TrimSpace(item), code) { + return true + } + } + return false +} + +func parseClockMinutes(value string) (int, bool) { + var hour int + var minute int + if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d:%d", &hour, &minute); err != nil { + return 0, false + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, false + } + return hour*60 + minute, true +} + +func weekdayCode(weekday time.Weekday) string { + switch weekday { + case time.Monday: + return "mon" + case time.Tuesday: + return "tue" + case time.Wednesday: + return "wed" + case time.Thursday: + return "thu" + case time.Friday: + return "fri" + case time.Saturday: + return "sat" + default: + return "sun" + } +} + func incidentDurationSeconds(startedAt time.Time, endedAt *time.Time) int64 { end := time.Now() if endedAt != nil { diff --git a/internal/services/repository.go b/internal/services/repository.go index 4c888c0..f129dc2 100644 --- a/internal/services/repository.go +++ b/internal/services/repository.go @@ -22,10 +22,10 @@ func (r *Repository) DB() *sql.DB { func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) (models.Organization, error) { var organization models.Organization err := r.db.QueryRowContext(ctx, ` - SELECT id, name, theme, theme_mode, created_at + SELECT id, name, theme, theme_mode, auto_update_window_start, auto_update_window_end, auto_update_days, created_at FROM organizations LIMIT 1 - `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt) + `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.AutoUpdateWindowStart, &organization.AutoUpdateWindowEnd, &organization.AutoUpdateDays, &organization.CreatedAt) if err == nil { return organization, nil } @@ -47,10 +47,10 @@ func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) func (r *Repository) GetOrganization(ctx context.Context) (models.Organization, error) { var organization models.Organization err := r.db.QueryRowContext(ctx, ` - SELECT id, name, theme, theme_mode, created_at + SELECT id, name, theme, theme_mode, auto_update_window_start, auto_update_window_end, auto_update_days, created_at FROM organizations LIMIT 1 - `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt) + `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.AutoUpdateWindowStart, &organization.AutoUpdateWindowEnd, &organization.AutoUpdateDays, &organization.CreatedAt) return organization, err } @@ -63,6 +63,15 @@ func (r *Repository) UpdateOrganizationTheme(ctx context.Context, theme, themeMo return err } +func (r *Repository) UpdateOrganizationAutoUpdateWindow(ctx context.Context, start, end, days string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE organizations + SET auto_update_window_start = ?, auto_update_window_end = ?, auto_update_days = ? + WHERE id = (SELECT id FROM organizations LIMIT 1) + `, start, end, days) + return err +} + func (r *Repository) CountUsers(ctx context.Context) (int, error) { var count int err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) @@ -173,7 +182,7 @@ func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error { func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGroup, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, organization_id, name, description, color_token, created_at + SELECT id, organization_id, name, description, color_token, icon, created_at FROM vm_groups WHERE organization_id = ? ORDER BY name ASC @@ -186,7 +195,7 @@ func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGr var groups []models.VMGroup for rows.Next() { var group models.VMGroup - if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.CreatedAt); err != nil { + if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.Icon, &group.CreatedAt); err != nil { return nil, err } groups = append(groups, group) @@ -197,9 +206,9 @@ func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGr func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) error { result, err := r.db.ExecContext(ctx, ` - INSERT INTO vm_groups (organization_id, name, description, color_token) - VALUES (?, ?, ?, ?) - `, group.OrganizationID, group.Name, group.Description, group.ColorToken) + INSERT INTO vm_groups (organization_id, name, description, color_token, icon) + VALUES (?, ?, ?, ?, ?) + `, group.OrganizationID, group.Name, group.Description, group.ColorToken, group.Icon) if err != nil { return err } @@ -208,6 +217,15 @@ func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) err return nil } +func (r *Repository) UpdateGroup(ctx context.Context, group *models.VMGroup) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE vm_groups + SET name = ?, description = ?, color_token = ?, icon = ? + WHERE id = ? AND organization_id = ? + `, group.Name, group.Description, group.ColorToken, group.Icon, group.ID, group.OrganizationID) + return err +} + func (r *Repository) ListTags(ctx context.Context, orgID int64) ([]string, error) { rows, err := r.db.QueryContext(ctx, ` SELECT DISTINCT tag diff --git a/internal/views/layouts/app.gohtml b/internal/views/layouts/app.gohtml index cd2b719..9598352 100644 --- a/internal/views/layouts/app.gohtml +++ b/internal/views/layouts/app.gohtml @@ -16,6 +16,12 @@ + {{else if eq .CurrentPath "/updates"}} +
+ + {{else if eq .CurrentPath "/uptime"}}
+
@@ -147,6 +176,82 @@
+ + + +
MonitorStartedEndedDurationErrorMonitorStartedEndedDurationError
- - - {{range $data.Nodes}} - +{{if $data.Groups}} +
+ {{range $data.Groups}} +
+
+
+
+
+
+ +
+
+

{{.Group.Name}}

+ {{if .Group.Description}}
{{.Group.Description}}
{{end}} +
+
+ {{if and $.User (ne $.User.Role "viewer")}} + {{end}} -
-
NameGroupDistroIPUptime
{{.Name}}{{if .GroupName}}{{.GroupName}}{{end}}{{.Distro}}{{.IPAddress}}{{uptime .UptimeSeconds}}
+
+
+ VMs + {{.VMCount}} +
-
+
+ {{end}} {{else}}
- No nodes. + No groups yet.
{{end}} diff --git a/internal/views/pages/node.gohtml b/internal/views/pages/node.gohtml index d22154c..41c83d3 100644 --- a/internal/views/pages/node.gohtml +++ b/internal/views/pages/node.gohtml @@ -5,6 +5,11 @@

{{$data.Node.Name}}

+ {{if and $.User (ne $.User.Role "viewer")}} + + {{end}} Open Console @@ -22,6 +27,93 @@
+{{if and $.User (ne $.User.Role "viewer")}} + +{{end}} +
+
+ +{{if gt $data.TotalPages 1}} +
+
Page {{$data.Page}} of {{$data.TotalPages}}
+
{{end}} + + +{{end}} diff --git a/internal/views/pages/updates.gohtml b/internal/views/pages/updates.gohtml new file mode 100644 index 0000000..c0eda80 --- /dev/null +++ b/internal/views/pages/updates.gohtml @@ -0,0 +1,263 @@ +{{define "updatesNodeRows"}} + {{range .}} + + +
{{.Name}}
+
{{packageManagerLabel .PackageManager}}{{if .UpdatesLastError}} · {{.UpdatesLastError}}{{end}}
+ + +
+ {{.UpdatesAvailable}} + {{if and (gt .UpdatesAvailable 0) .UpdatesDetails}} + + {{end}} +
+ + {{if .UpdatesLastChecked}}{{.UpdatesLastChecked.Format "2006-01-02 15:04:05"}}{{else}}Never{{end}} + +
+ + + + + + +
+ +
+ +
+
+ +
+
+ + + {{end}} +{{end}} + +{{define "content"}} +{{$data := .Content}} +
+
+
+
+
Pending packages
+
{{$data.TotalUpdates}}
+
+
+
+
+
+
+
Nodes with updates
+
{{$data.NodesWithUpdates}}
+
+
+
+
+
+
+
Scanned nodes
+
{{$data.ScannedNodes}}
+
+
+
+
+
+
+
Auto update
+
{{$data.AutoUpdateNodes}}
+
+
+
+
+ +
+
+
+
Global Auto Update Window
+
{{updateWindowSummary $data.GlobalWindowStart $data.GlobalWindowEnd $data.GlobalUpdateDays}}
+
Automatic package upgrades respect this org-wide schedule so updates stay out of peak hours.
+
+
+ +
+ +
+
+ +
+
+
+
+ +{{range $data.Groups}} +
+
+
+
+

{{.Name}}

+
{{.UpdatesAvailable}} updates on {{.NodesWithUpdates}} nodes
+
+
+
+ +
+
+ +
+
+
+
+ + + +
+
+ + + + + + + + + + + + + {{template "updatesNodeRows" .Nodes}} + +
NodeUpdatesLast ScanScanAutoActions
+
+
+
+{{end}} + +{{if $data.Ungrouped}} +
+
+

Ungrouped

+
+ + + + + + + + + + + + + {{template "updatesNodeRows" $data.Ungrouped}} + +
NodeUpdatesLast ScanScanAutoActions
+
+
+
+{{end}} + + + + +{{end}} diff --git a/internal/views/views.go b/internal/views/views.go index be8a2fd..46b1568 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -2,12 +2,15 @@ package views import ( "embed" + "encoding/json" "html/template" "net/http" "path" "strconv" "strings" "time" + + "maintainarr/internal/models" ) //go:embed layouts/*.gohtml pages/*.gohtml @@ -41,6 +44,10 @@ func NewRenderer() (*Renderer, error) { "nodeIconPending": nodeIconPending, "packageManagerIconClass": packageManagerIconClass, "packageManagerLabel": packageManagerLabel, + "groupIcons": groupIcons, + "hasUpdateDay": hasUpdateDay, + "updateWindowSummary": updateWindowSummary, + "updatePackages": updatePackages, "uptime": formatUptime, "safeHTML": func(value string) template.HTML { return template.HTML(value) }, "nowYear": func() int { return time.Now().Year() }, @@ -194,6 +201,83 @@ func packageManagerLabel(value string) string { } } +func updatePackages(value string) []models.NodeUpdatePackage { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + var packages []models.NodeUpdatePackage + if err := json.Unmarshal([]byte(trimmed), &packages); err != nil { + return nil + } + return packages +} + +func groupIcons() []string { + return []string{ + "ti ti-stack-2", "ti ti-server", "ti ti-server-2", "ti ti-server-bolt", "ti ti-server-cog", "ti ti-server-off", + "ti ti-server-spark", "ti ti-cloud", "ti ti-cloud-bolt", "ti ti-cloud-cog", "ti ti-cloud-code", "ti ti-cloud-data-connection", + "ti ti-cloud-computing", "ti ti-cloud-check", "ti ti-cloud-up", "ti ti-cloud-down", "ti ti-cloud-lock", "ti ti-cloud-off", + "ti ti-cloud-x", "ti ti-database", "ti ti-database-export", "ti ti-database-import", "ti ti-database-cog", "ti ti-database-off", + "ti ti-database-search", "ti ti-database-smile", "ti ti-table", "ti ti-table-options", "ti ti-table-export", "ti ti-table-import", + "ti ti-network", "ti ti-router", "ti ti-route", "ti ti-world", "ti ti-world-www", "ti ti-access-point", + "ti ti-wifi", "ti ti-wifi-off", "ti ti-antenna-bars-5", "ti ti-broadcast", "ti ti-radar-2", "ti ti-satellite", + "ti ti-satellite-off", "ti ti-direction-sign", "ti ti-git-branch", "ti ti-git-commit", "ti ti-git-merge", "ti ti-git-pull-request", + "ti ti-brand-docker", "ti ti-brand-kubernetes", "ti ti-brand-github", "ti ti-brand-gitlab", "ti ti-brand-openai", "ti ti-brand-aws", + "ti ti-brand-google", "ti ti-brand-windows", "ti ti-brand-debian", "ti ti-brand-ubuntu", "ti ti-brand-archlinux", "ti ti-brand-python", + "ti ti-brand-javascript", "ti ti-brand-typescript", "ti ti-brand-go", "ti ti-brand-c-sharp", "ti ti-brand-vscode", "ti ti-brand-powershell", + "ti ti-device-desktop", "ti ti-device-imac", "ti ti-device-laptop", "ti ti-device-tablet", "ti ti-device-mobile", "ti ti-devices", + "ti ti-devices-2", "ti ti-screen-share", "ti ti-screen-share-off", "ti ti-app-window", "ti ti-browser", "ti ti-browser-check", + "ti ti-browser-cog", "ti ti-browser-off", "ti ti-layout-dashboard", "ti ti-layout-grid", "ti ti-layout-list", "ti ti-layout-kanban", + "ti ti-layout-navbar", "ti ti-layout-sidebar", "ti ti-menu-2", "ti ti-panel-left", "ti ti-panel-right", "ti ti-panorama-horizontal", + "ti ti-package", "ti ti-package-import", "ti ti-package-export", "ti ti-package-off", "ti ti-box", "ti ti-box-multiple", + "ti ti-box-model", "ti ti-archive", "ti ti-archive-off", "ti ti-folders", "ti ti-folder", "ti ti-folder-open", + "ti ti-folder-cog", "ti ti-folder-bolt", "ti ti-file-stack", "ti ti-file-database", "ti ti-file-settings", "ti ti-file-code", + "ti ti-file-zip", "ti ti-files", "ti ti-scan", "ti ti-search", "ti ti-search-off", "ti ti-filter", + "ti ti-filter-cog", "ti ti-filter-search", "ti ti-zoom-scan", "ti ti-map-search", "ti ti-binary", "ti ti-braces", + "ti ti-brackets", "ti ti-code", "ti ti-code-circle", "ti ti-code-dots", "ti ti-code-plus", "ti ti-code-minus", + "ti ti-api", "ti ti-terminal", "ti ti-terminal-2", "ti ti-command", "ti ti-prompt", "ti ti-script", + "ti ti-bug", "ti ti-bug-off", "ti ti-test-pipe", "ti ti-progress-check", "ti ti-checks", "ti ti-checkup-list", + "ti ti-chart-bar", "ti ti-chart-donut", "ti ti-chart-line", "ti ti-chart-area", "ti ti-chart-histogram", "ti ti-chart-infographic", + "ti ti-activity", "ti ti-activity-heartbeat", "ti ti-pulse", "ti ti-timeline", "ti ti-gauge", "ti ti-meter-cube", + "ti ti-wave-sine", "ti ti-wave-square", "ti ti-wave-triangle", "ti ti-heart-rate-monitor", "ti ti-clock", "ti ti-clock-cog", + "ti ti-calendar-time", "ti ti-history", "ti ti-refresh", "ti ti-reload", "ti ti-repeat", "ti ti-rotate-clockwise-2", + "ti ti-cpu", "ti ti-cpu-2", "ti ti-microchip", "ti ti-memory", "ti ti-ram", "ti ti-gpu-card", + "ti ti-disc", "ti ti-disc-off", "ti ti-hard-drive", "ti ti-device-sd-card", "ti ti-sd-card", "ti ti-device-usb", + "ti ti-usb", "ti ti-plug", "ti ti-plug-connected", "ti ti-power", "ti ti-bolt", "ti ti-bolt-off", + "ti ti-battery", "ti ti-battery-vertical", "ti ti-sun-electricity", "ti ti-circle-dashed", "ti ti-circle-check", "ti ti-circle-x", + "ti ti-circle-plus", "ti ti-circle-minus", "ti ti-circle-key", "ti ti-key", "ti ti-key-off", "ti ti-keyframe", + "ti ti-lock", "ti ti-lock-access", "ti ti-lock-check", "ti ti-shield", "ti ti-shield-check", "ti ti-shield-lock", + "ti ti-shield-half-filled", "ti ti-shield-x", "ti ti-eye-shield", "ti ti-fingerprint", "ti ti-automation", "ti ti-robot", + "ti ti-settings", "ti ti-settings-2", "ti ti-settings-automation", "ti ti-adjustments", "ti ti-adjustments-alt", "ti ti-adjustments-bolt", + "ti ti-tool", "ti ti-tools", "ti ti-wrench", "ti ti-hammer", "ti ti-screwdriver", "ti ti-stethoscope", + "ti ti-tags", "ti ti-tag", "ti ti-tag-starred", "ti ti-pin", "ti ti-pinned", "ti ti-bookmark", + "ti ti-flag", "ti ti-flag-2", "ti ti-user-cog", "ti ti-user-shield", "ti ti-users-group", "ti ti-building-community", + } +} + +func hasUpdateDay(days, code string) bool { + for _, item := range strings.Split(strings.ToLower(strings.TrimSpace(days)), ",") { + if strings.TrimSpace(item) == strings.ToLower(strings.TrimSpace(code)) { + return true + } + } + return false +} + +func updateWindowSummary(start, end, days string) string { + if strings.TrimSpace(start) == "" || strings.TrimSpace(end) == "" { + if strings.TrimSpace(days) == "" { + return "Any time" + } + return strings.ToUpper(strings.ReplaceAll(days, ",", " ")) + } + if strings.TrimSpace(days) == "" { + return start + " - " + end + } + return strings.ToUpper(strings.ReplaceAll(days, ",", " ")) + " · " + start + " - " + end +} + func icon(name string) template.HTML { icons := map[string]string{ "dashboard": ``, diff --git a/web/static/css/app.css b/web/static/css/app.css index 9c65472..5f806f5 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -446,7 +446,7 @@ body[data-bs-theme="light"] .app-nav-link.is-active { .node-tile { display: grid; - grid-template-columns: 64px minmax(0, 1fr); + grid-template-columns: 66px minmax(0, 1fr); min-height: 118px; } @@ -505,11 +505,17 @@ body[data-bs-theme="light"] .app-nav-link.is-active { .node-brand-icon { width: 100%; - height: 92%; - font-size: 4.5rem; + height: 82%; + font-size: 3.2rem; align-self: center; } +.node-brand-icon .distro-icon, +.node-brand-icon .node-loading-icon { + font-size: 3.25rem; + line-height: 1; +} + .node-loading-icon { animation: node-icon-spin 1s linear infinite; } @@ -999,6 +1005,308 @@ body[data-bs-theme="light"] .theme-card { height: auto; } +.updates-policy-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.updates-window-fields { + display: grid; + gap: 0.45rem; + min-width: 13rem; +} + +.updates-window-times { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.updates-window-times span { + font-size: 0.74rem; + color: var(--bs-secondary-color); + white-space: nowrap; +} + +.updates-window-times .form-control { + min-width: 0; +} + +.updates-weekdays { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.updates-day-chip { + position: relative; + cursor: pointer; +} + +.updates-day-chip input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.updates-day-chip span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + min-height: 1.85rem; + padding: 0 0.45rem; + border: 1px solid var(--ma-border); + border-radius: 999px; + background: var(--ma-surface-1); + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; +} + +.updates-day-chip input:checked + span { + border-color: rgb(var(--color-primary-500)); + background: color-mix(in srgb, rgb(var(--color-primary-500)) 10%, transparent); + color: var(--bs-emphasis-color); +} + +.updates-window-editor { + display: grid; + gap: 1rem; +} + +.updates-weekdays-lg { + gap: 0.6rem; +} + +.updates-day-chip-lg span { + min-width: 6.75rem; + min-height: 2.5rem; + padding: 0 1rem; + font-size: 0.84rem; +} + +.updates-package-list { + display: grid; + gap: 0.3rem; +} + +.updates-package-item { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + font-size: 0.78rem; + line-height: 1.3; +} + +.updates-package-name { + color: var(--bs-emphasis-color); + font-weight: 600; +} + +.updates-package-version { + color: var(--bs-secondary-color); + word-break: break-word; +} + +.updates-details-trigger { + padding: 0; + font-size: 0.78rem; + text-decoration: none; +} + +.updates-details-trigger:hover, +.updates-details-trigger:focus { + text-decoration: underline; +} + +.updates-packages-table th, +.updates-packages-table td { + padding: 0.9rem 1rem; + vertical-align: middle; +} + +.group-icon-field { + width: 100%; + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.9rem 1rem; + border: 1px solid var(--ma-border); + border-radius: 1rem; + background: var(--ma-surface-1); + color: inherit; + text-align: left; +} + +.group-icon-field-preview { + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.95rem; + border: 1px solid var(--ma-border); + background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2)); + color: var(--bs-emphasis-color); + font-size: 1.4rem; + flex: 0 0 auto; +} + +.group-icon-field-text { + min-width: 0; + display: grid; + gap: 0.1rem; + flex: 1 1 auto; +} + +.group-icon-field-title { + font-weight: 700; + color: var(--bs-emphasis-color); +} + +.group-icon-field-subtitle { + font-size: 0.8rem; + color: var(--bs-secondary-color); +} + +.group-icon-field-arrow { + color: var(--bs-secondary-color); + font-size: 1rem; +} + +.group-icon-search { + position: relative; +} + +.group-icon-search-icon { + position: absolute; + top: 50%; + left: 0.95rem; + transform: translateY(-50%); + color: var(--bs-secondary-color); + pointer-events: none; +} + +.group-icon-search .form-control { + padding-left: 2.5rem; +} + +.group-icon-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 0.75rem; + max-height: 28rem; + overflow: auto; + padding-right: 0.2rem; +} + +.group-icon-option { + min-height: 5.25rem; + padding: 0.8rem 0.6rem; + display: grid; + align-content: center; + justify-items: center; + gap: 0.45rem; + cursor: pointer; + transition: border-color 0.18s ease, color 0.18s ease, background-color 0.18s ease; + text-align: center; +} + +.group-icon-option i { + font-size: 1.35rem; +} + +.group-icon-option span { + font-size: 0.7rem; + line-height: 1.2; + word-break: break-word; +} + +.group-icon-option.is-active, +.group-icon-option:hover, +.group-icon-option:focus { + border-color: rgb(var(--color-primary-500)); + background: color-mix(in srgb, rgb(var(--color-primary-500)) 10%, transparent); + color: var(--bs-emphasis-color); +} + +.group-node-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--ma-border); + border-radius: 0.95rem; + background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2)); +} + +.group-node-row:hover { + background: color-mix(in srgb, rgb(var(--color-primary-500)) 8%, transparent); +} + +.logs-table th, +.logs-table td { + padding: 0.9rem 1rem; + vertical-align: middle; +} + +.logs-command-cell { + min-width: 0; + width: 100%; +} + +.logs-command-button { + display: block; + width: 100%; + padding: 0; + color: inherit; + text-align: left; + text-decoration: none; +} + +.logs-command-button:hover, +.logs-command-button:focus { + color: inherit; + text-decoration: none; +} + +.logs-command-preview { + display: -webkit-box; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.logs-modal-section + .logs-modal-section { + margin-top: 1rem; +} + +.logs-modal-label { + margin-bottom: 0.45rem; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.logs-modal-pre { + margin: 0; + padding: 1rem; + border: 1px solid var(--ma-border); + border-radius: 1rem; + background: var(--ma-surface-1); + color: var(--bs-emphasis-color); + white-space: pre-wrap; + word-break: break-word; +} + .uptime-summary-card { background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 92%, transparent), var(--ma-surface-1)); } diff --git a/web/static/js/app.js b/web/static/js/app.js index e662e09..873e711 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -11,6 +11,30 @@ document.addEventListener("DOMContentLoaded", () => { const themeRadios = document.querySelectorAll('input[name="theme"]'); const themeModeInput = document.querySelector('input[name="mode"]'); const uptimeChart = document.getElementById("uptime-chart"); + const commandLogModal = document.getElementById("commandLogModal"); + const updatePackagesModal = document.getElementById("updatePackagesModal"); + const editGroupModal = document.getElementById("editGroupModal"); + const groupIconPickerModal = document.getElementById("groupIconPickerModal"); + const groupIconSearch = document.querySelector("[data-group-icon-search]"); + const groupIconGrid = document.querySelector("[data-group-icon-grid]"); + let activeGroupIconInput = null; + let activeGroupIconPreview = ""; + + const setGroupIconPreview = (previewId, iconClass) => { + const preview = document.getElementById(previewId); + if (!(preview instanceof HTMLElement)) { + return; + } + preview.innerHTML = ``; + }; + + const highlightSelectedGroupIcon = (iconClass) => { + groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => { + if (option instanceof HTMLElement) { + option.classList.toggle("is-active", option.dataset.iconOption === iconClass); + } + }); + }; const attachXtermConsole = (consoleOutput) => { const wsPath = consoleOutput.dataset.ws; @@ -79,6 +103,174 @@ document.addEventListener("DOMContentLoaded", () => { attachXtermConsole(output); } + if (commandLogModal instanceof HTMLElement) { + commandLogModal.addEventListener("show.bs.modal", (event) => { + const trigger = event.relatedTarget; + if (!(trigger instanceof HTMLElement)) { + return; + } + + const target = trigger.dataset.logTarget || ""; + const time = trigger.dataset.logTime || ""; + const status = trigger.dataset.logStatus || ""; + const duration = trigger.dataset.logDuration || ""; + const command = trigger.dataset.logCommand || ""; + const outputText = trigger.dataset.logOutput || ""; + + const meta = commandLogModal.querySelector("[data-command-log-meta]"); + const commandNode = commandLogModal.querySelector("[data-command-log-command]"); + const outputNode = commandLogModal.querySelector("[data-command-log-output]"); + + if (meta) { + meta.textContent = [target, time, status, duration].filter(Boolean).join(" · "); + } + if (commandNode) { + commandNode.textContent = command || "-"; + } + if (outputNode) { + outputNode.textContent = outputText || "No output"; + } + }); + } + + if (updatePackagesModal instanceof HTMLElement) { + updatePackagesModal.addEventListener("show.bs.modal", (event) => { + const trigger = event.relatedTarget; + if (!(trigger instanceof HTMLElement)) { + return; + } + + const nodeName = trigger.dataset.nodeName || ""; + const rawPackages = trigger.dataset.packages || "[]"; + const nodeLabel = updatePackagesModal.querySelector("[data-update-packages-node]"); + const body = updatePackagesModal.querySelector("[data-update-packages-body]"); + + if (nodeLabel) { + nodeLabel.textContent = nodeName; + } + + if (!(body instanceof HTMLElement)) { + return; + } + + let packages = []; + try { + packages = JSON.parse(rawPackages); + } catch (_) { + packages = []; + } + + if (!Array.isArray(packages) || packages.length === 0) { + body.innerHTML = `No packages.`; + return; + } + + body.innerHTML = packages.map((pkg) => ` + + ${pkg.Name || "-"} + ${pkg.Current || "-"} + ${pkg.Available || "-"} + ${pkg.Architecture || "-"} + + `).join(""); + }); + } + + if (editGroupModal instanceof HTMLElement) { + editGroupModal.addEventListener("show.bs.modal", (event) => { + const trigger = event.relatedTarget; + if (!(trigger instanceof HTMLElement)) { + return; + } + + const groupId = trigger.dataset.groupId || ""; + const groupName = trigger.dataset.groupName || ""; + const groupDescription = trigger.dataset.groupDescription || ""; + const groupIcon = trigger.dataset.groupIcon || "ti ti-stack-2"; + const form = editGroupModal.querySelector("[data-edit-group-form]"); + const nameInput = editGroupModal.querySelector("[data-edit-group-name]"); + const descriptionInput = editGroupModal.querySelector("[data-edit-group-description]"); + const iconInput = document.getElementById("editGroupIconValue"); + const iconTrigger = editGroupModal.querySelector("[data-edit-group-icon-trigger]"); + + if (form instanceof HTMLFormElement) { + form.action = `/groups/${groupId}`; + } + if (nameInput instanceof HTMLInputElement) { + nameInput.value = groupName; + } + if (descriptionInput instanceof HTMLTextAreaElement) { + descriptionInput.value = groupDescription; + } + if (iconInput instanceof HTMLInputElement) { + iconInput.value = groupIcon; + } + setGroupIconPreview("editGroupIconPreview", groupIcon); + if (iconTrigger instanceof HTMLElement) { + iconTrigger.dataset.iconValue = groupIcon; + } + }); + } + + if (groupIconPickerModal instanceof HTMLElement) { + groupIconPickerModal.addEventListener("show.bs.modal", (event) => { + const trigger = event.relatedTarget; + if (!(trigger instanceof HTMLElement)) { + return; + } + + activeGroupIconInput = document.getElementById(trigger.dataset.iconInput || ""); + activeGroupIconPreview = trigger.dataset.iconPreview || ""; + const currentValue = trigger.dataset.iconValue || (activeGroupIconInput instanceof HTMLInputElement ? activeGroupIconInput.value : "ti ti-stack-2"); + + if (groupIconSearch instanceof HTMLInputElement) { + groupIconSearch.value = ""; + } + groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => { + if (option instanceof HTMLElement) { + option.classList.remove("d-none"); + } + }); + highlightSelectedGroupIcon(currentValue); + window.setTimeout(() => groupIconSearch?.focus(), 120); + }); + + groupIconGrid?.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const option = target.closest("[data-icon-option]"); + if (!(option instanceof HTMLElement) || !(activeGroupIconInput instanceof HTMLInputElement)) { + return; + } + + const iconClass = option.dataset.iconOption || "ti ti-stack-2"; + activeGroupIconInput.value = iconClass; + if (activeGroupIconPreview) { + setGroupIconPreview(activeGroupIconPreview, iconClass); + } + document.querySelectorAll(`[data-icon-input="${activeGroupIconInput.id}"]`).forEach((trigger) => { + if (trigger instanceof HTMLElement) { + trigger.dataset.iconValue = iconClass; + } + }); + highlightSelectedGroupIcon(iconClass); + bootstrap.Modal.getInstance(groupIconPickerModal)?.hide(); + }); + + groupIconSearch?.addEventListener("input", () => { + const query = groupIconSearch.value.trim().toLowerCase(); + groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => { + if (!(option instanceof HTMLElement)) { + return; + } + const label = option.dataset.iconOption || ""; + option.classList.toggle("d-none", query !== "" && !label.toLowerCase().includes(query)); + }); + }); + } + const syncScheduleUi = () => { if (!triggerType || !scheduleKind) { return; @@ -237,19 +429,20 @@ document.addEventListener("DOMContentLoaded", () => { if (dashboardNodes instanceof HTMLElement) { const url = dashboardNodes.dataset.dashboardNodesUrl; if (url) { - fetch(url, { headers: { Accept: "text/html" } }) - .then((response) => { + const loadDashboardNodes = async (showErrorState) => { + try { + const response = await fetch(url, { headers: { Accept: "text/html" } }); if (!response.ok) { throw new Error("failed"); } - return response.text(); - }) - .then((html) => { + const html = await response.text(); dashboardNodes.innerHTML = html; dashboardNodes.classList.add("is-loaded"); dashboardSearch?.dispatchEvent(new Event("input")); - }) - .catch(() => { + } catch (_) { + if (!showErrorState) { + return; + } dashboardNodes.innerHTML = `
@@ -257,7 +450,13 @@ document.addEventListener("DOMContentLoaded", () => {
`; - }); + } + }; + + loadDashboardNodes(true); + window.setInterval(() => { + loadDashboardNodes(false); + }, 5000); } } From a3164991878ba0bf483367e57212de0c1965fdbf Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 19:59:49 -0500 Subject: [PATCH 06/13] fix(updates): clarify package modal copy --- internal/views/pages/updates.gohtml | 4 ++-- web/static/js/app.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/views/pages/updates.gohtml b/internal/views/pages/updates.gohtml index c0eda80..473cb9d 100644 --- a/internal/views/pages/updates.gohtml +++ b/internal/views/pages/updates.gohtml @@ -17,7 +17,7 @@ data-node-name="{{.Name}}" data-packages="{{.UpdatesDetails}}" > - View + View packages {{end}} @@ -251,7 +251,7 @@ - No packages. + No pending packages. diff --git a/web/static/js/app.js b/web/static/js/app.js index 873e711..7640670 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -161,7 +161,7 @@ document.addEventListener("DOMContentLoaded", () => { } if (!Array.isArray(packages) || packages.length === 0) { - body.innerHTML = `No packages.`; + body.innerHTML = `No pending packages.`; return; } From 344d57dbe5c03c365e5accfb7d58905a02e911a5 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 20:00:07 -0500 Subject: [PATCH 07/13] feat(updates): show package counts in modal header --- internal/views/pages/updates.gohtml | 1 + web/static/js/app.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/views/pages/updates.gohtml b/internal/views/pages/updates.gohtml index 473cb9d..0b6e3b0 100644 --- a/internal/views/pages/updates.gohtml +++ b/internal/views/pages/updates.gohtml @@ -15,6 +15,7 @@ data-bs-toggle="modal" data-bs-target="#updatePackagesModal" data-node-name="{{.Name}}" + data-package-count="{{.UpdatesAvailable}}" data-packages="{{.UpdatesDetails}}" > View packages diff --git a/web/static/js/app.js b/web/static/js/app.js index 7640670..4664e6f 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -141,12 +141,13 @@ document.addEventListener("DOMContentLoaded", () => { } const nodeName = trigger.dataset.nodeName || ""; + const packageCount = trigger.dataset.packageCount || "0"; const rawPackages = trigger.dataset.packages || "[]"; const nodeLabel = updatePackagesModal.querySelector("[data-update-packages-node]"); const body = updatePackagesModal.querySelector("[data-update-packages-body]"); if (nodeLabel) { - nodeLabel.textContent = nodeName; + nodeLabel.textContent = `${nodeName} | ${packageCount} pending`; } if (!(body instanceof HTMLElement)) { From d97390694fc9a905b0c90bf9e8ecdbca9b7606d6 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 20:01:36 -0500 Subject: [PATCH 08/13] fix(updates): widen package details modal --- internal/views/pages/updates.gohtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/views/pages/updates.gohtml b/internal/views/pages/updates.gohtml index 0b6e3b0..d08ef26 100644 --- a/internal/views/pages/updates.gohtml +++ b/internal/views/pages/updates.gohtml @@ -230,7 +230,7 @@