package webui
import (
"context"
"database/sql"
"fmt"
"html"
"io"
"strings"
"time"
"github.com/a-h/templ"
"satoru/internal/store"
)
type RuntimeCheck struct {
Name string
Installed bool
Details string
}
type WorkflowStage struct {
Title string
Description string
}
type DashboardData struct {
Now time.Time
ScanInterval time.Duration
User store.User
Sites []store.Site
RuntimeChecks []RuntimeCheck
WorkflowStages []WorkflowStage
FlashMessage string
}
func Dashboard(data DashboardData) templ.Component {
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
role := "Operator"
if data.User.IsAdmin {
role = "Admin"
}
var checks strings.Builder
for _, c := range data.RuntimeChecks {
status := "missing"
if c.Installed {
status = "installed"
}
checks.WriteString(fmt.Sprintf(`
%s%s
`,
status,
html.EscapeString(c.Name),
html.EscapeString(c.Details)))
}
var flows strings.Builder
for _, s := range data.WorkflowStages {
flows.WriteString(fmt.Sprintf(`
%s
%s
`,
html.EscapeString(s.Title),
html.EscapeString(s.Description)))
}
var sites strings.Builder
if len(data.Sites) == 0 {
sites.WriteString(`
No sites added yet. Add your first Linux SSH site below.
`)
}
for _, site := range data.Sites {
last := "Never run"
if site.LastRunAt.Valid {
last = site.LastRunAt.Time.Local().Format(time.RFC1123)
}
runStatus := "pending"
if site.LastRunStatus.Valid {
runStatus = site.LastRunStatus.String
}
runOutput := "(no output yet)"
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
runOutput = site.LastRunOutput.String
}
scanState := "pending"
if site.LastScanState.Valid && site.LastScanState.String != "" {
scanState = site.LastScanState.String
}
lastScan := "Never"
if site.LastScanAt.Valid {
lastScan = site.LastScanAt.Time.Local().Format(time.RFC1123)
}
scanNotes := "No scan yet."
if site.LastScanNotes.Valid && site.LastScanNotes.String != "" {
scanNotes = site.LastScanNotes.String
}
var targets strings.Builder
if len(site.Targets) == 0 {
targets.WriteString(`
%s
`,
html.EscapeString(data.User.Username),
html.EscapeString(role),
flash,
checks.String(),
flows.String(),
sites.String(),
))
return err
})
}
func formatFlash(code string) string {
switch code {
case "site-added":
return "Site added."
case "job-queued":
return "Backup job queued."
case "job-cancel-requested":
return "Cancel requested for active job."
case "job-restarted":
return "Backup restart queued."
case "no-active-job":
return "No active job to cancel."
case "site-updated":
return "Site updated."
case "site-deleted":
return "Site deleted."
case "site-invalid":
return "SSH user, host, and at least one target path are required."
case "site-invalid-path":
return "Target paths must be absolute Linux paths starting with '/'."
case "site-invalid-port":
return "Port must be an integer between 1 and 65535."
case "password-updated":
return "Password updated."
case "mysql-added":
return "MySQL dump operation added."
case "mysql-invalid":
return "MySQL host, user, database, and password are required."
case "target-deleted":
return "Target removed."
case "target-not-found":
return "Target not found."
default:
return code
}
}
func targetModeLabel(mode string) string {
if mode == "sqlite_dump" {
return "sqlite dump"
}
if mode == "mysql_dump" {
return "mysql dump"
}
return "directory"
}
func joinTargetPaths(targets []store.SiteTarget, mode string) string {
var out []string
for _, t := range targets {
if t.Mode == mode {
out = append(out, t.Path)
}
}
return strings.Join(out, "\n")
}
func formatFilters(filters []string) string {
if len(filters) == 0 {
return "(none)"
}
return strings.Join(filters, ", ")
}
func targetModeClass(mode string) string {
if mode == "sqlite_dump" || mode == "mysql_dump" {
return "sqlite"
}
return "ok"
}
func formatBytes(v int64) string {
if v < 1024 {
return fmt.Sprintf("%d B", v)
}
units := []string{"KB", "MB", "GB", "TB"}
value := float64(v)
for _, u := range units {
value /= 1024
if value < 1024 {
return fmt.Sprintf("%.1f %s", value, u)
}
}
return fmt.Sprintf("%.1f PB", value/1024)
}
func timeUntilNextScan(now time.Time, lastScan sql.NullTime, interval time.Duration) string {
if !lastScan.Valid {
return "due now"
}
next := lastScan.Time.Add(interval)
if !next.After(now) {
return "due now"
}
d := next.Sub(now).Round(time.Minute)
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh %dm", h, m)
}