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 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(`
  • none (no targets)
  • `) } for _, t := range site.Targets { sizeOrErr := "size pending" if t.LastError.Valid && t.LastError.String != "" { sizeOrErr = "error: " + t.LastError.String } else if t.LastSizeByte.Valid { sizeOrErr = formatBytes(t.LastSizeByte.Int64) } targets.WriteString(fmt.Sprintf(`
  • %s %s%s
  • `, targetModeClass(t.Mode), html.EscapeString(targetModeLabel(t.Mode)), html.EscapeString(t.Path), html.EscapeString(sizeOrErr))) } sites.WriteString(fmt.Sprintf(`

    %s@%s:%d

    Backup targets:

    Scan: %s · Last: %s · Next: %s

    %s

    Last run: %s

    Status: %s

    %s
    `, html.EscapeString(site.SSHUser), html.EscapeString(site.Host), site.Port, site.ID, targets.String(), html.EscapeString(scanState), html.EscapeString(scanState), html.EscapeString(lastScan), html.EscapeString(timeUntilNextScan(data.Now, site.LastScanAt)), html.EscapeString(scanNotes), html.EscapeString(last), html.EscapeString(runStatus), html.EscapeString(runStatus), html.EscapeString(runOutput), )) } flash := "" if data.FlashMessage != "" { flash = fmt.Sprintf(`

    %s

    `, html.EscapeString(formatFlash(data.FlashMessage))) } _, err := io.WriteString(w, fmt.Sprintf(` Satoru Dashboard

    Satoru

    Managed Sites Overview

    Signed in as %s (%s).

    Change password
    %s

    Host Runtime Status

    Planned Workflow

      %s

    Add Site

    Managed Sites

    %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 "site-ran": return "Run completed." 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." default: return code } } func targetModeLabel(mode string) string { if mode == "sqlite_dump" { return "sqlite dump" } return "directory" } func targetModeClass(mode string) string { if mode == "sqlite_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) string { if !lastScan.Valid { return "due now" } next := lastScan.Time.Add(24 * time.Hour) 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) }