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 var siteNav strings.Builder if len(data.Sites) == 0 { sites.WriteString(`

    No sites added yet. Add your first Linux SSH site below.

    `) siteNav.WriteString(`

    No sites yet.

    `) } 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 } siteNav.WriteString(fmt.Sprintf(``, site.ID, html.EscapeString(site.SSHUser), html.EscapeString(site.Host), html.EscapeString(runStatus), site.ID, html.EscapeString(runStatus), html.EscapeString(scanState), site.ID, html.EscapeString(scanState), )) 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.Mode == "mysql_dump" && t.LastScanAt.Valid { sizeOrErr = "connection established" } else if t.LastSizeByte.Valid { sizeOrErr = formatBytes(t.LastSizeByte.Int64) } label := targetModeLabel(t.Mode) pathText := t.Path if t.Mode == "mysql_dump" { label = "mysql dump" if t.MySQLHost.Valid && t.MySQLUser.Valid && t.MySQLDB.Valid { pathText = fmt.Sprintf("db=%s user=%s host=%s", t.MySQLDB.String, t.MySQLUser.String, t.MySQLHost.String) } } targets.WriteString(fmt.Sprintf(`
  • %s %s%s
  • `, targetModeClass(t.Mode), html.EscapeString(label), html.EscapeString(pathText), html.EscapeString(sizeOrErr), site.ID, t.ID)) } sites.WriteString(fmt.Sprintf(`

    %s@%s:%d

    Edit site
    Add MySQL dump operation

    Backup targets:

    Filters: %s

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

    %s

    Last run: %s

    Status: %s

    Current step: idle

    %s
    `, site.ID, html.EscapeString(site.SSHUser), html.EscapeString(site.Host), site.Port, site.ID, site.ID, site.ID, site.ID, site.ID, html.EscapeString(site.SSHUser), html.EscapeString(site.Host), site.Port, html.EscapeString(joinTargetPaths(site.Targets, "directory")), html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")), html.EscapeString(strings.Join(site.Filters, "\n")), site.ID, targets.String(), html.EscapeString(formatFilters(site.Filters)), html.EscapeString(scanState), html.EscapeString(scanState), html.EscapeString(lastScan), html.EscapeString(timeUntilNextScan(data.Now, site.LastScanAt, data.ScanInterval)), html.EscapeString(scanNotes), html.EscapeString(last), html.EscapeString(runStatus), site.ID, html.EscapeString(runStatus), site.ID, site.ID, site.ID, site.ID, site.ID, 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

    Add Site

    Active Site

    %s

    Active Site Logs

    • Waiting for job updates.
    No live events yet.

    Planned Workflow

      %s
    `, html.EscapeString(data.User.Username), html.EscapeString(role), flash, siteNav.String(), checks.String(), sites.String(), flows.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) }