diff --git a/cmd/squirrel/main.go b/cmd/squirrel/main.go index 223fab0..bea55e0 100644 --- a/cmd/squirrel/main.go +++ b/cmd/squirrel/main.go @@ -2,16 +2,33 @@ package main import ( "context" + "errors" + "fmt" "os" "os/signal" "syscall" ) +// exitCodeError carries a specific process exit status out of a command's +// RunE. `squirrel status` uses it to report its scriptable green/amber/red +// contract (exit 0/1/2), which the generic "any error ⇒ exit 1" path below +// cannot express. errors.As recovers the code here; the command that +// returns it silences cobra's error printing so nothing but the code +// leaves the process. +type exitCodeError struct{ code int } + +func (e exitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.code) } + func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := newRootCmd().ExecuteContext(ctx); err != nil { + err := newRootCmd().ExecuteContext(ctx) + var ec exitCodeError + if errors.As(err, &ec) { + os.Exit(ec.code) + } + if err != nil { os.Exit(1) } } diff --git a/cmd/squirrel/root.go b/cmd/squirrel/root.go index 4bb9def..9083efb 100644 --- a/cmd/squirrel/root.go +++ b/cmd/squirrel/root.go @@ -46,6 +46,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newRunsCmd()) root.AddCommand(newHooksCmd()) root.AddCommand(newVolumesCmd()) + root.AddCommand(newStatusCmd()) root.AddCommand(newSyncCmd()) root.AddCommand(newRestoreCmd()) root.AddCommand(newOffloadCmd()) diff --git a/cmd/squirrel/status.go b/cmd/squirrel/status.go new file mode 100644 index 0000000..67f22b3 --- /dev/null +++ b/cmd/squirrel/status.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "io" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/mbertschler/squirrel/status" +) + +// newStatusCmd returns the `squirrel status [volume]` cobra command: a +// read-only "am I safe?" grid answering, per (volume × target), whether +// every configured target is caught up and whether the volume's local +// content is durable where its offload policy needs it (friction-log F16, +// F17, F23). It mutates nothing. The process exit code reflects the worst +// state — 0 green, 1 amber, 2 red — so the same command scripts a health +// check without parsing the grid. +func newStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status [volume]", + Short: "Show per-target sync coverage and durability, and the offload-ready total", + Args: cobra.MaximumNArgs(1), + // The amber/red signal is carried out as an exitCodeError; silence + // cobra's own error printing so the grid is the only output and the + // signal shows up purely in the exit status. + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var volume string + if len(args) == 1 { + volume = args[0] + } + err := runStatus(cmd, volume) + // SilenceErrors keeps the scriptable green/amber/red exit-code + // signal (an exitCodeError) from leaking a cobra "Error:" line. But + // it also silences genuine failures — an unknown volume, a missing + // config, a store error — which must still reach the user, so print + // those here. The exit-code signal stays silent. + var ec exitCodeError + if err != nil && !errors.As(err, &ec) { + fmt.Fprintln(cmd.ErrOrStderr(), cmd.ErrPrefix(), err.Error()) + } + return err + }, + } + return cmd +} + +func runStatus(cmd *cobra.Command, volume string) error { + cfg, err := requireConfig(cmd) + if err != nil { + return err + } + if volume != "" { + if _, ok := cfg.Volumes[volume]; !ok { + return fmt.Errorf("unknown volume %q (declare it in %s)", volume, cfg.Path) + } + } + s, err := openStore(cmd, cfg) + if err != nil { + return err + } + defer s.Close() + + rep, err := status.Build(cmd.Context(), s, cfg) + if err != nil { + return err + } + worst := renderStatus(cmd.OutOrStdout(), rep, volume) + if code := worst.ExitCode(); code != 0 { + return exitCodeError{code: code} + } + return nil +} + +// renderStatus prints the grid and returns the worst level across the +// volumes it printed. When volume is non-empty only that volume is shown, +// and only its level drives the return (so a scripted per-volume check +// isn't reddened by an unrelated volume). +func renderStatus(w io.Writer, rep status.Report, volume string) status.Level { + worst := status.LevelNeutral + for _, v := range rep.Volumes { + if volume != "" && v.Name != volume { + continue + } + renderStatusVolume(w, v) + if v.Level() > worst { + worst = v.Level() + } + } + fmt.Fprintf(w, "overall: %s\n", status.TrafficLight(worst)) + return worst +} + +// renderStatusVolume prints one volume's header, its target grid, and its +// offload-readiness line, reusing the shared status labels so the wording +// matches the TUI exactly. +func renderStatusVolume(w io.Writer, v status.VolumeStatus) { + fmt.Fprintf(w, "\n%s %s [%s]\n", v.Name, v.Path, status.TrafficLight(v.Level())) + fmt.Fprintf(w, " index: %s\n", status.IndexLabel(v)) + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " TARGET\tROLE\tLAST SYNC\tSTATE\tDURABLE\tMETHOD\tEVIDENCE") + for _, t := range v.Targets { + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\t%s\n", + t.Name, status.RoleLabel(t), status.LastSyncLabel(t), status.StateLabel(t), + status.DurableLabel(t), status.MethodLabel(t), status.EvidenceLabel(t)) + } + _ = tw.Flush() + fmt.Fprintf(w, " %s\n", status.OffloadLabel(v.Offload)) +} diff --git a/cmd/squirrel/status_test.go b/cmd/squirrel/status_test.go new file mode 100644 index 0000000..3b96ba8 --- /dev/null +++ b/cmd/squirrel/status_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeStatusConfig lays down a volume with a sync destination, plus its +// DB, and returns the config path. Callers index the volume themselves. +func writeStatusConfig(t *testing.T) (configPath string) { + t.Helper() + root := t.TempDir() + volumeDir := filepath.Join(root, "pics") + if err := os.MkdirAll(volumeDir, 0o755); err != nil { + t.Fatalf("mkdir volume: %v", err) + } + writeTestFile(t, filepath.Join(volumeDir, "a.txt"), "hello") + writeTestFile(t, filepath.Join(volumeDir, "b.txt"), "world") + + destDir := filepath.Join(root, "dst") + if err := os.MkdirAll(destDir, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + dbPath := filepath.Join(root, "index.db") + configPath = filepath.Join(root, "config.toml") + body := "" + + "db = \"" + dbPath + "\"\n\n" + + "[destinations.scratch]\n" + + "type = \"local\"\n" + + "root = \"" + destDir + "\"\n\n" + + "[volumes.pics]\n" + + "path = \"" + volumeDir + "\"\n" + + "sync_to = [\"scratch\"]\n" + + "sync_every = \"1h\"\n" + if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return configPath +} + +// TestCLIStatusNeverSyncedIsAmber: a configured but never-synced target +// leaves the volume amber, and the command exits with code 1 carried by an +// exitCodeError. +func TestCLIStatusNeverSyncedIsAmber(t *testing.T) { + cfg := writeStatusConfig(t) + runCLI(t, "--config", cfg, "index", "pics") + + out, err := runCLIExpectErr(t, "--config", cfg, "status") + var ec exitCodeError + if !errors.As(err, &ec) { + t.Fatalf("expected exitCodeError, got %v", err) + } + if ec.code != 1 { + t.Fatalf("exit code = %d, want 1 (amber)", ec.code) + } + if !strings.Contains(out, "pics") || !strings.Contains(out, "scratch") { + t.Fatalf("grid missing volume/target:\n%s", out) + } + if !strings.Contains(out, "never") { + t.Fatalf("never-synced target should read 'never':\n%s", out) + } + if !strings.Contains(out, "overall: amber") { + t.Fatalf("summary should be amber:\n%s", out) + } + // Cobra's own "Error:" line must be silenced — only the grid prints. + if strings.Contains(out, "Error:") { + t.Fatalf("cobra error line leaked into output:\n%s", out) + } +} + +// TestCLIStatusAfterSyncIsGreen: once a fresh sync lands, the pair is +// caught up within cadence and the command exits 0. +func TestCLIStatusAfterSyncIsGreen(t *testing.T) { + requireRcloneCLI(t) + cfg := writeStatusConfig(t) + runCLI(t, "--config", cfg, "index", "pics") + runCLI(t, "--config", cfg, "sync", "pics", "--init") + + out := runCLI(t, "--config", cfg, "status") + if !strings.Contains(out, "overall: green") { + t.Fatalf("expected green after a fresh sync:\n%s", out) + } + if !strings.Contains(out, "ok") { + t.Fatalf("expected an 'ok' target state:\n%s", out) + } +} + +// TestCLIStatusUnknownVolume errors clearly on a volume not in config. +// SilenceErrors (needed to keep the amber/red exit-code signal quiet) must +// not swallow this genuine error: it has to reach the user's stderr, not +// just the returned error value. +func TestCLIStatusUnknownVolume(t *testing.T) { + cfg := writeStatusConfig(t) + out, err := runCLIExpectErr(t, "--config", cfg, "status", "nope") + if err == nil || !strings.Contains(err.Error(), `unknown volume "nope"`) { + t.Fatalf("expected unknown-volume error, got %v", err) + } + if !strings.Contains(out, `unknown volume "nope"`) { + t.Fatalf("error must reach the user, not be silenced:\n%s", out) + } +} + +// TestCLIStatusRequiresConfig: status is a question about configured +// coverage, so a missing config is a clear error, not an empty grid — and +// the message must be printed, not silently swallowed by SilenceErrors. +func TestCLIStatusRequiresConfig(t *testing.T) { + missing := filepath.Join(t.TempDir(), "no-config.toml") + out, err := runCLIExpectErr(t, "--config", missing, "status") + if err == nil || !strings.Contains(err.Error(), "no config at") { + t.Fatalf("expected missing-config error, got %v", err) + } + if !strings.Contains(out, "no config at") { + t.Fatalf("error must reach the user, not be silenced:\n%s", out) + } +} diff --git a/offload/readiness.go b/offload/readiness.go new file mode 100644 index 0000000..bbc1a09 --- /dev/null +++ b/offload/readiness.go @@ -0,0 +1,88 @@ +package offload + +import ( + "context" + "time" + + "github.com/mbertschler/squirrel/store" +) + +// ReadinessOptions selects the volume and durability policy for a +// Readiness evaluation. It mirrors the durability-relevant subset of +// Options: no path/age selectors (readiness is always whole-volume) and no +// DryRun flag (readiness never touches disk or opens a run). +type ReadinessOptions struct { + // VolumeID is the local volumes row the gate is evaluated against. + VolumeID int64 + // Require is the volume's offload_requires policy. Empty means the + // volume has no offload policy, and Readiness reports Applicable=false + // without evaluating anything. + Require []string + // MaxEvidenceAge is the volume's offload_max_evidence_age; it feeds the + // same time-based staleness gate an offload would apply. Zero disables + // the staleness policy. + MaxEvidenceAge time.Duration +} + +// ReadinessReport totals what an offload of the whole volume would move +// right now. OffloadableFiles/Bytes are the subset of the present set that +// passes the durability gate; PresentFiles/Bytes are the whole gated +// candidate set, so a UI can render "N of M". +type ReadinessReport struct { + // Applicable is false when the volume declares no offload policy — the + // caller can then distinguish "nothing offloadable" from "offload not + // configured here" without inspecting the totals. + Applicable bool + OffloadableFiles int + OffloadableBytes int64 + PresentFiles int + PresentBytes int64 +} + +// Readiness reports how many present files, and how many bytes, currently +// pass the offload gate for the volume — the "N GB offloadable now" +// decision-support number (friction log F17) without the side effects of +// `offload --dry-run`. It reads only the local index (durability vectors, +// scan-back fingerprints), opens no run, reads no disk bytes, validates no +// volume marker, and deletes nothing, so it is safe to call from a +// read-only introspection surface at any time. +// +// The gate is evaluated exactly as Offload's dry-run path evaluates it — +// same loadGate, same present-and-not-reserved candidate predicate, same +// per-file check — so the totals match what an offload would actually move. +// Totals are order-independent, so this walks the index map in a single +// pass rather than building and sorting an intermediate candidate slice. +func Readiness(ctx context.Context, s *store.Store, opts ReadinessOptions) (ReadinessReport, error) { + if len(opts.Require) == 0 { + return ReadinessReport{Applicable: false}, nil + } + now := time.Now() + g, err := loadGate(ctx, s, opts.VolumeID, opts.Require, now.UnixNano(), opts.MaxEvidenceAge) + if err != nil { + return ReadinessReport{}, err + } + rows, err := s.LoadVolumeIndex(ctx, opts.VolumeID) + if err != nil { + return ReadinessReport{}, err + } + rep := ReadinessReport{Applicable: true} + for p, row := range rows { + if err := ctx.Err(); err != nil { + return ReadinessReport{}, err + } + if row.Status != store.StatusPresent || underReservedSubtree(p) { + continue + } + rep.PresentFiles++ + rep.PresentBytes += row.SizeBytes + failures, err := g.check(ctx, row) + if err != nil { + return ReadinessReport{}, err + } + if len(failures) == 0 { + rep.OffloadableFiles++ + rep.OffloadableBytes += row.SizeBytes + } + } + return rep, nil +} diff --git a/status/build.go b/status/build.go new file mode 100644 index 0000000..918cc09 --- /dev/null +++ b/status/build.go @@ -0,0 +1,276 @@ +package status + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" + + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/offload" + "github.com/mbertschler/squirrel/store" +) + +// lateFactor is how many cadence periods a pair may fall behind before its +// staleness reads red rather than amber. Within one cadence is on-time +// (green); up to lateFactor cadences is late (amber); beyond is a +// destination that has effectively stopped (red). +const lateFactor = 3 + +// Options tunes Build. +type Options struct { + // SkipOffloadReadiness omits the per-volume offload-readiness tally. + // offload.Readiness loads the whole volume index and runs the gate over + // every present file, so on large volumes it is the report's dominant + // cost. The TUI dashboard sets it on its one-second tick (and refreshes + // the tally on a slower cadence into its own cache) so the coverage and + // durability grid stays responsive; the CLI `squirrel status` leaves it + // off so the "N offloadable now" figure is always current. When set, a + // volume's Offload still reports Applicable from its config policy, just + // with zero counts. + SkipOffloadReadiness bool +} + +// Build produces the status Report for this node with readiness computed — +// the CLI's view and the default. See BuildWithOptions for the tunable +// form the TUI uses. +func Build(ctx context.Context, s *store.Store, cfg *config.Config) (Report, error) { + return BuildWithOptions(ctx, s, cfg, Options{}) +} + +// BuildWithOptions produces the status Report for this node: every +// config-declared volume with its per-target coverage and durability. It is +// read-only — no run rows, no disk reads, no mutation — so it is safe to +// call from any introspection surface at any time. cfg is the source of +// truth for which volumes and targets should exist; the store supplies the +// observations. +func BuildWithOptions(ctx context.Context, s *store.Store, cfg *config.Config, opts Options) (Report, error) { + rep := Report{Now: time.Now()} + self, err := s.GetSelfNode(ctx) + if err != nil { + return Report{}, fmt.Errorf("look up self node: %w", err) + } + alarms, err := s.ListDestinationAlarms(ctx) + if err != nil { + return Report{}, fmt.Errorf("list destination alarms: %w", err) + } + alarmByDest := indexAlarms(alarms) + for _, name := range sortedVolumeNames(cfg) { + vs, err := buildVolume(ctx, s, cfg, self, alarmByDest, cfg.Volumes[name], rep.Now, opts) + if err != nil { + return Report{}, err + } + rep.Volumes = append(rep.Volumes, vs) + } + return rep, nil +} + +// indexAlarms keys the active alarms by destination name for O(1) lookup +// per target. Alarms latch per destination, not per volume, so one entry +// applies to every volume that names the destination. +func indexAlarms(alarms []store.DestinationAlarm) map[string]store.DestinationAlarm { + out := make(map[string]store.DestinationAlarm, len(alarms)) + for _, a := range alarms { + out[a.Destination] = a + } + return out +} + +// sortedVolumeNames returns the config's volume names in deterministic +// order so the report (and the CLI grid) is stable across runs. +func sortedVolumeNames(cfg *config.Config) []string { + names := make([]string, 0, len(cfg.Volumes)) + for name := range cfg.Volumes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// buildVolume assembles one volume's status. A volume with no index row +// (declared but never indexed) still lists its planned targets so the +// grid shows the configured coverage rather than a blank line. +func buildVolume(ctx context.Context, s *store.Store, cfg *config.Config, self store.Node, alarmByDest map[string]store.DestinationAlarm, vol *config.Volume, now time.Time, opts Options) (VolumeStatus, error) { + vs := VolumeStatus{Name: vol.Name, Path: vol.Path} + // Applicable reflects the config policy, not whether a tally ran, so a + // never-indexed or skip-readiness volume still reports "has a policy". + vs.Offload.Applicable = len(vol.OffloadRequires) > 0 + dbVol, err := s.GetVolumeByName(ctx, vol.Name) + if store.IsNotFound(err) { + vs.Targets, err = buildTargets(ctx, s, cfg, self, alarmByDest, vol, 0, false, 0, now) + return vs, err + } + if err != nil { + return VolumeStatus{}, fmt.Errorf("look up volume %q: %w", vol.Name, err) + } + vs.Indexed = true + vs.LastIndexAgo, err = lastIndexAge(ctx, s, dbVol.ID, now) + if err != nil { + return VolumeStatus{}, err + } + selfMax, err := selfOriginMax(ctx, s, dbVol.ID, self.ID) + if err != nil { + return VolumeStatus{}, err + } + vs.Targets, err = buildTargets(ctx, s, cfg, self, alarmByDest, vol, dbVol.ID, true, selfMax, now) + if err != nil { + return VolumeStatus{}, err + } + if opts.SkipOffloadReadiness { + return vs, nil + } + readiness, err := offload.Readiness(ctx, s, offload.ReadinessOptions{ + VolumeID: dbVol.ID, Require: vol.OffloadRequires, MaxEvidenceAge: vol.OffloadMaxEvidenceAge, + }) + if err != nil { + return VolumeStatus{}, fmt.Errorf("offload readiness for %q: %w", vol.Name, err) + } + vs.Offload = OffloadReadiness(readiness) + return vs, nil +} + +// buildTargets builds a TargetStatus for every target in the union of the +// volume's sync_to and offload_requires, sync targets first in config +// order then relayed offload targets, deduplicated. +func buildTargets(ctx context.Context, s *store.Store, cfg *config.Config, self store.Node, alarmByDest map[string]store.DestinationAlarm, vol *config.Volume, volID int64, indexed bool, selfMax int64, now time.Time) ([]TargetStatus, error) { + var out []TargetStatus + for _, name := range orderedTargets(vol) { + ts, err := buildTarget(ctx, s, cfg, self, alarmByDest, vol, name, volID, indexed, selfMax, now) + if err != nil { + return nil, err + } + out = append(out, ts) + } + return out, nil +} + +// orderedTargets returns the union of sync_to and offload_requires, +// sync_to first in declared order, then required targets not already +// listed, deduplicated. +func orderedTargets(vol *config.Volume) []string { + seen := make(map[string]struct{}) + var out []string + for _, group := range [][]string{vol.SyncTo, vol.OffloadRequires} { + for _, name := range group { + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + } + return out +} + +// buildTarget assembles one (volume × target) cell: coverage freshness, +// standing state, and durability. +func buildTarget(ctx context.Context, s *store.Store, cfg *config.Config, self store.Node, alarmByDest map[string]store.DestinationAlarm, vol *config.Volume, name string, volID int64, indexed bool, selfMax int64, now time.Time) (TargetStatus, error) { + ts := TargetStatus{ + Name: name, + Kind: targetKind(cfg, name), + SyncTarget: contains(vol.SyncTo, name), + Required: contains(vol.OffloadRequires, name), + Cadence: vol.SyncEvery, + } + var lastTerminal *store.Run + if indexed { + lastSync, err := latestSuccessfulSync(ctx, s, volID, name) + if err != nil { + return TargetStatus{}, err + } + ts.LastSyncAgo = ageOf(lastSync, now) + if lastTerminal, err = latestTerminalSync(ctx, s, volID, name); err != nil { + return TargetStatus{}, err + } + if lastTerminal != nil { + ts.LastOutcome = lastTerminal.Status + } + } + if alarm, ok := alarmByDest[name]; ok { + ts.Standing = StandingAlarm + ts.AlarmDetail = alarm.Detail + } else { + ts.Standing = classifyStanding(lastTerminal, ts.LastSyncAgo != nil) + } + ts.SyncLevel = syncLevel(ts, lastTerminal) + if indexed { + dur, err := buildDurability(ctx, s, volID, self, selfMax, vol, name, now) + if err != nil { + return TargetStatus{}, err + } + ts.Durability = dur + } + return ts, nil +} + +// classifyStanding derives the standing state from the pair's latest +// terminal sync run. A refusal that regressed from a prior success is a +// broken destination (red); a bootstrap-style refusal on a pair that has +// never succeeded is a fresh destination awaiting one-time `--init` (amber). +func classifyStanding(lastTerminal *store.Run, hadSuccess bool) Standing { + if lastTerminal == nil || lastTerminal.Status != store.RunStatusRefused { + return StandingNone + } + if !hadSuccess && isBootstrapRefusal(lastTerminal.Error) { + return StandingNeedsBootstrap + } + return StandingRefused +} + +// isBootstrapRefusal reports whether a refused run's error is a first-use +// bootstrap refusal (a missing destination marker or an unconnected kopia +// repository), both of which tell the operator to "re-run with --init". +// The layout guard and the init-over-a-different-volume refusal carry no +// "--init" hint, so they stay classified as plain refusals. +func isBootstrapRefusal(errVal sql.NullString) bool { + return errVal.Valid && strings.Contains(errVal.String, "--init") +} + +// syncLevel scores the coverage dimension: standing states first, then the +// latest terminal outcome (a failed run is red, a partial run at least +// amber — both are more recent than any success, so they must not be +// masked by freshness), then freshness relative to the pair's own cadence. +// A relayed target (not pushed to by this node) has no coverage dimension — +// its safety is entirely its durability — so it reads neutral. +func syncLevel(ts TargetStatus, lastTerminal *store.Run) Level { + switch ts.Standing { + case StandingAlarm, StandingRefused: + return LevelCritical + case StandingNeedsBootstrap: + return LevelWarn + } + if lastTerminal != nil { + switch lastTerminal.Status { + case store.RunStatusFailed: + return LevelCritical + case store.RunStatusPartial: + return LevelWarn + } + } + if !ts.SyncTarget { + return LevelNeutral + } + if ts.LastSyncAgo == nil { + return LevelWarn + } + return cadenceLevel(*ts.LastSyncAgo, ts.Cadence) +} + +// cadenceLevel colours a successful sync's age against the pair's cadence: +// within cadence is green, up to lateFactor cadences amber, beyond red. A +// zero cadence (no scheduled sync) cannot be late, so any success is green. +func cadenceLevel(age, cadence time.Duration) Level { + if cadence <= 0 { + return LevelOK + } + switch { + case age <= cadence: + return LevelOK + case age <= lateFactor*cadence: + return LevelWarn + default: + return LevelCritical + } +} diff --git a/status/build_test.go b/status/build_test.go new file mode 100644 index 0000000..798cca8 --- /dev/null +++ b/status/build_test.go @@ -0,0 +1,303 @@ +package status + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/index" + "github.com/mbertschler/squirrel/store" +) + +func setupStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.Open(filepath.Join(t.TempDir(), "status.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// indexTree writes three files into a fresh directory and indexes it as +// volume name, returning the root and the index run. +func indexTree(t *testing.T, s *store.Store, name string) (string, index.Report) { + t.Helper() + root := t.TempDir() + for _, n := range []string{"a.txt", "b.txt", "c.txt"} { + if err := os.WriteFile(filepath.Join(root, n), []byte("content-"+n), 0o644); err != nil { + t.Fatalf("write %s: %v", n, err) + } + } + rep, err := index.Index(context.Background(), s, root, index.Options{Name: name, Workers: 2}) + if err != nil || rep.Errors > 0 { + t.Fatalf("index %s: err=%v errors=%v", name, err, rep.ErrorList) + } + return root, rep +} + +// cfgFor builds a minimal config with one volume and its targets. Every +// volume gets a one-hour sync cadence so a fresh sync reads as on-time. +func cfgFor(volName, root string, syncTo, requires []string, dests, nodes []string) *config.Config { + cfg := &config.Config{ + Volumes: map[string]*config.Volume{}, + Destinations: map[string]*config.Destination{}, + Nodes: map[string]*config.Node{}, + } + cfg.Volumes[volName] = &config.Volume{ + Name: volName, + Path: root, + SyncTo: syncTo, + OffloadRequires: requires, + SyncEvery: time.Hour, + } + for _, d := range dests { + cfg.Destinations[d] = &config.Destination{Name: d, Type: "s3", Layout: config.LayoutContentAddressed} + } + for _, n := range nodes { + cfg.Nodes[n] = &config.Node{Name: n} + } + return cfg +} + +func recordSuccessfulSync(t *testing.T, s *store.Store, volID int64, dest string) { + t.Helper() + id, blocker, err := s.BeginSyncRunIfClear(context.Background(), store.SyncRunSpec{VolumeID: volID, Destination: dest}) + if err != nil || blocker != nil { + t.Fatalf("BeginSyncRunIfClear(%s): err=%v blocker=%+v", dest, err, blocker) + } + if err := s.FinishRun(context.Background(), id, store.RunStatusSuccess, "", 3); err != nil { + t.Fatalf("FinishRun(%s): %v", dest, err) + } +} + +// TestBuildFullyDurable exercises the happy path end to end: an indexed +// volume, a fresh successful sync, a content-verified durability vector +// covering the local origin, and an offload policy — everything green and +// every file offloadable. +func TestBuildFullyDurable(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + root, idx := indexTree(t, s, "photos") + v, err := s.GetVolumeByName(ctx, "photos") + if err != nil { + t.Fatalf("GetVolumeByName: %v", err) + } + self, err := s.GetSelfNode(ctx) + if err != nil { + t.Fatalf("GetSelfNode: %v", err) + } + if err := s.UpsertDestinationRunIDVerified(ctx, v.ID, "dest", self.ID, idx.RunID, store.VerifyMethodBlake3, false); err != nil { + t.Fatalf("seed vector: %v", err) + } + recordSuccessfulSync(t, s, v.ID, "dest") + + cfg := cfgFor("photos", root, []string{"dest"}, []string{"dest"}, []string{"dest"}, nil) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(rep.Volumes) != 1 { + t.Fatalf("volumes = %d, want 1", len(rep.Volumes)) + } + vs := rep.Volumes[0] + if !vs.Indexed || vs.LastIndexAgo == nil { + t.Fatalf("volume not indexed/aged: %+v", vs) + } + if len(vs.Targets) != 1 { + t.Fatalf("targets = %d, want 1", len(vs.Targets)) + } + tg := vs.Targets[0] + if tg.Kind != KindDestination || !tg.SyncTarget || !tg.Required { + t.Errorf("target role wrong: %+v", tg) + } + if tg.Standing != StandingNone || tg.SyncLevel != LevelOK { + t.Errorf("target coverage = standing %v level %v, want none/ok", tg.Standing, tg.SyncLevel) + } + if tg.Durability == nil || !tg.Durability.LocalContent || !tg.Durability.Covered || tg.Durability.Method != store.VerifyMethodBlake3 { + t.Errorf("durability wrong: %+v", tg.Durability) + } + if !vs.Offload.Applicable || vs.Offload.OffloadableFiles != 3 || vs.Offload.PresentFiles != 3 { + t.Errorf("offload readiness wrong: %+v", vs.Offload) + } + if vs.Offload.OffloadableBytes != vs.Offload.PresentBytes || vs.Offload.OffloadableBytes == 0 { + t.Errorf("offload bytes wrong: %+v", vs.Offload) + } + if rep.Level() != LevelOK { + t.Errorf("report level = %v, want ok", rep.Level()) + } +} + +// TestBuildNeedsBootstrap: a refused sync whose reason names --init, with +// no prior success, is the amber one-time-bootstrap state — not red. +func TestBuildNeedsBootstrap(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + root, _ := indexTree(t, s, "docs") + v, _ := s.GetVolumeByName(ctx, "docs") + id, err := s.BeginRun(ctx, store.RunKindSync, v.ID, "dest", false) + if err != nil { + t.Fatalf("BeginRun: %v", err) + } + if err := s.FinishRun(ctx, id, store.RunStatusRefused, "destination \"dest\" has no marker — re-run with --init to bootstrap", 0); err != nil { + t.Fatalf("FinishRun: %v", err) + } + cfg := cfgFor("docs", root, []string{"dest"}, nil, []string{"dest"}, nil) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + tg := rep.Volumes[0].Targets[0] + if tg.Standing != StandingNeedsBootstrap { + t.Errorf("standing = %v, want needs-bootstrap", tg.Standing) + } + if rep.Level() != LevelWarn { + t.Errorf("report level = %v, want amber", rep.Level()) + } +} + +// TestBuildAlarm: a latched destination alarm reddens its target. +func TestBuildAlarm(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + root, idx := indexTree(t, s, "media") + if _, err := s.RaiseDestinationAlarm(ctx, "dest", store.AlarmKindVerifyMismatch, "checksum mismatch on object", idx.RunID); err != nil { + t.Fatalf("RaiseDestinationAlarm: %v", err) + } + cfg := cfgFor("media", root, []string{"dest"}, nil, []string{"dest"}, nil) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + tg := rep.Volumes[0].Targets[0] + if tg.Standing != StandingAlarm || tg.AlarmDetail == "" { + t.Errorf("standing = %v detail=%q, want alarm", tg.Standing, tg.AlarmDetail) + } + if rep.Level() != LevelCritical { + t.Errorf("report level = %v, want red", rep.Level()) + } +} + +// TestBuildNeverIndexed: a configured-but-unindexed volume is amber and +// lists its planned targets rather than rendering blank (F4). +func TestBuildNeverIndexed(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + cfg := cfgFor("photos", "/does/not/matter", []string{"nas"}, nil, nil, []string{"nas"}) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + vs := rep.Volumes[0] + if vs.Indexed { + t.Errorf("volume should not be indexed") + } + if len(vs.Targets) != 1 || vs.Targets[0].Kind != KindNode { + t.Errorf("planned targets wrong: %+v", vs.Targets) + } + if vs.Level() != LevelWarn { + t.Errorf("volume level = %v, want amber", vs.Level()) + } + // No offload policy configured here, so it must not claim one. + if vs.Offload.Applicable { + t.Errorf("offload should not be applicable without a policy") + } +} + +// TestBuildNeverIndexedReportsPolicy: a never-indexed volume that DOES +// declare offload_requires must report Applicable so the surface shows its +// policy state, not "no policy" (Copilot review on #177). +func TestBuildNeverIndexedReportsPolicy(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + cfg := cfgFor("photos", "/does/not/matter", []string{"nas"}, []string{"nas", "s3archive"}, nil, []string{"nas"}) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + vs := rep.Volumes[0] + if vs.Indexed { + t.Errorf("volume should not be indexed") + } + if !vs.Offload.Applicable { + t.Errorf("offload should be applicable — the config declares offload_requires") + } + if vs.Offload.OffloadableFiles != 0 || vs.Offload.PresentFiles != 0 { + t.Errorf("never-indexed volume should report zero counts: %+v", vs.Offload) + } +} + +// TestBuildSkipOffloadReadiness: the TUI tick path skips the whole-index +// gate pass but still reports the policy flag; the CLI path computes the +// counts. Both see the same coverage/durability grid. +func TestBuildSkipOffloadReadiness(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + root, idx := indexTree(t, s, "photos") + v, _ := s.GetVolumeByName(ctx, "photos") + self, _ := s.GetSelfNode(ctx) + if err := s.UpsertDestinationRunIDVerified(ctx, v.ID, "dest", self.ID, idx.RunID, store.VerifyMethodBlake3, false); err != nil { + t.Fatalf("seed vector: %v", err) + } + recordSuccessfulSync(t, s, v.ID, "dest") + cfg := cfgFor("photos", root, []string{"dest"}, []string{"dest"}, []string{"dest"}, nil) + + skipped, err := BuildWithOptions(ctx, s, cfg, Options{SkipOffloadReadiness: true}) + if err != nil { + t.Fatalf("BuildWithOptions(skip): %v", err) + } + so := skipped.Volumes[0].Offload + if !so.Applicable || so.OffloadableFiles != 0 || so.PresentFiles != 0 { + t.Errorf("skip-readiness offload = %+v, want applicable with zero counts", so) + } + full, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build(full): %v", err) + } + fo := full.Volumes[0].Offload + if !fo.Applicable || fo.OffloadableFiles != 3 || fo.PresentFiles != 3 { + t.Errorf("full offload = %+v, want 3 offloadable of 3 present", fo) + } + // The coverage grid is identical regardless of the readiness option. + if len(skipped.Volumes[0].Targets) != len(full.Volumes[0].Targets) { + t.Errorf("target count differs between skip and full builds") + } +} + +// TestBuildRelayedRequiredTarget: a target named only in offload_requires +// (never in sync_to) reads as relayed for coverage but is scored on its +// durability; with no evidence yet it is amber, not red. +func TestBuildRelayedRequiredTarget(t *testing.T) { + s := setupStore(t) + ctx := context.Background() + root, _ := indexTree(t, s, "photos") + cfg := cfgFor("photos", root, []string{"nas"}, []string{"nas", "s3archive"}, nil, []string{"nas"}) + rep, err := Build(ctx, s, cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + var relayed *TargetStatus + for i := range rep.Volumes[0].Targets { + if rep.Volumes[0].Targets[i].Name == "s3archive" { + relayed = &rep.Volumes[0].Targets[i] + } + } + if relayed == nil { + t.Fatalf("relayed target s3archive missing from grid") + } + if relayed.SyncTarget || !relayed.Required || relayed.Kind != KindRelayed { + t.Errorf("relayed target classification wrong: %+v", relayed) + } + if relayed.SyncLevel != LevelNeutral { + t.Errorf("relayed sync level = %v, want neutral", relayed.SyncLevel) + } + if relayed.Durability == nil || relayed.Durability.Covered { + t.Errorf("relayed durability should be uncovered: %+v", relayed.Durability) + } + if relayed.Level() != LevelWarn { + t.Errorf("relayed target level = %v, want amber (evidence not yet arrived)", relayed.Level()) + } +} diff --git a/status/durability.go b/status/durability.go new file mode 100644 index 0000000..e814a1a --- /dev/null +++ b/status/durability.go @@ -0,0 +1,106 @@ +package status + +import ( + "context" + "time" + + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/store" +) + +// buildDurability computes the local-content durability of a volume on one +// target. Durability is tracked when the target is offload-required or when +// a vector component for it already exists; a non-required target with no +// recorded evidence returns nil (nothing to report). +func buildDurability(ctx context.Context, s *store.Store, volID int64, self store.Node, selfMax int64, vol *config.Volume, name string, now time.Time) (*Durability, error) { + comp, hasComp, err := selfComponent(ctx, s, volID, name, self.ID) + if err != nil { + return nil, err + } + required := contains(vol.OffloadRequires, name) + if !required && !hasComp { + return nil, nil + } + d := &Durability{ + LocalContent: selfMax > 0, + MaxEvidenceAge: vol.OffloadMaxEvidenceAge, + } + if hasComp { + d.Method = comp.VerifyMethod + d.Covered = comp.OriginRunID >= selfMax + d.EvidenceAge = evidenceAge(comp.VerifiedAtNs, now) + } + d.Stale = d.LocalContent && evidenceStale(d) + d.Level = durabilityLevel(d, required) + return d, nil +} + +// evidenceStale reports whether a max-evidence-age policy is set and the +// covering component's verification is older than it. It is fail-closed: +// an unknown evidence age (no component, or one that never carried a +// verification instant) counts as stale. +func evidenceStale(d *Durability) bool { + if d.MaxEvidenceAge <= 0 { + return false + } + if d.EvidenceAge == nil { + return true + } + return *d.EvidenceAge > d.MaxEvidenceAge +} + +// durabilityLevel scores the durability dimension. Content that does not +// originate locally is vacuously safe (neutral). A required target +// escalates to amber when local content is not yet covered or the evidence +// has aged out; a non-required target never escalates "am I safe?" — it +// shows green when healthy and neutral otherwise, since offload does not +// gate on it. +func durabilityLevel(d *Durability, required bool) Level { + if !d.LocalContent { + return LevelNeutral + } + if !required { + if d.Covered && !d.Stale { + return LevelOK + } + return LevelNeutral + } + if !d.Covered || d.Stale { + return LevelWarn + } + return LevelOK +} + +// selfComponent returns the target's durability-vector component for this +// node's own origin — the coverage of locally-introduced content, which is +// what the offload gate checks on an edge machine. The bool is false when +// the target has no component for the self node. +func selfComponent(ctx context.Context, s *store.Store, volID int64, name string, selfID int64) (store.DestinationRunID, bool, error) { + comps, err := s.ListDestinationRunIDs(ctx, volID, name) + if err != nil { + return store.DestinationRunID{}, false, err + } + for _, c := range comps { + if c.OriginNodeID == selfID { + return c, true, nil + } + } + return store.DestinationRunID{}, false, nil +} + +// selfOriginMax returns the highest origin run this node contributes to the +// volume's present set — the coverage floor the durability vector must +// reach for local content to count as durable. Zero means this node +// originates no present content in the volume. +func selfOriginMax(ctx context.Context, s *store.Store, volID, selfID int64) (int64, error) { + comps, err := s.PresentOriginMaxima(ctx, volID, selfID) + if err != nil { + return 0, err + } + for _, c := range comps { + if c.OriginNodeID == selfID { + return c.OriginRunID, nil + } + } + return 0, nil +} diff --git a/status/labels.go b/status/labels.go new file mode 100644 index 0000000..790d4d9 --- /dev/null +++ b/status/labels.go @@ -0,0 +1,170 @@ +package status + +import ( + "fmt" + "time" +) + +// This file holds the neutral text labels every surface renders from a +// Report. They live in the query layer so the CLI grid and the TUI +// dashboard show identical words for identical facts (the "build the +// shared layer once, consume it from both" contract); each surface adds +// only its own colour and framing. The functions return strings and never +// write to any stream, keeping the library free of I/O. + +// HumanAge renders a duration in the largest useful whole unit (5s, 3m, +// 2h, 4d), the compact form both the CLI grid and the TUI use for ages. +func HumanAge(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// IndexLabel renders a volume's index freshness or the never-indexed gap +// (friction-log F4 — a configured-but-unindexed volume must say so, not +// render blank). +func IndexLabel(v VolumeStatus) string { + if !v.Indexed || v.LastIndexAgo == nil { + return "never indexed" + } + return HumanAge(*v.LastIndexAgo) + " ago" +} + +// RoleLabel names why a target is in the grid: a sync destination, an +// offload-gating target, or both. +func RoleLabel(t TargetStatus) string { + switch { + case t.SyncTarget && t.Required: + return "sync+offload" + case t.SyncTarget: + return "sync" + case t.Required: + return "offload" + default: + return "—" + } +} + +// LastSyncLabel renders the last successful sync's age, "never" for a +// configured-but-unsynced target, or "relayed" for a target this node +// never pushes to (its evidence arrives via a peer durability pull). +func LastSyncLabel(t TargetStatus) string { + if !t.SyncTarget { + return "relayed" + } + if t.LastSyncAgo == nil { + return "never" + } + return HumanAge(*t.LastSyncAgo) + " ago" +} + +// StateLabel names the coverage state, most-specific first: standing states +// outrank the latest terminal outcome (a failed or partial run — both more +// recent than any success), which outranks freshness. +func StateLabel(t TargetStatus) string { + switch t.Standing { + case StandingAlarm: + return "alarm" + case StandingRefused: + return "refused" + case StandingNeedsBootstrap: + return "needs-init" + } + switch t.LastOutcome { + case "failed": + return "failed" + case "partial": + return "partial" + } + if !t.SyncTarget { + return "—" + } + if t.LastSyncAgo == nil { + return "never-synced" + } + switch t.SyncLevel { + case LevelWarn: + return "late" + case LevelCritical: + return "stalled" + default: + return "ok" + } +} + +// DurableLabel answers "is my local content durable here": yes/no for a +// tracked target, n/a when this node originates no content in the volume, +// and — when nothing is tracked at all. +func DurableLabel(t TargetStatus) string { + if t.Durability == nil { + return "—" + } + if !t.Durability.LocalContent { + return "n/a" + } + if t.Durability.Covered { + return "yes" + } + return "no" +} + +// MethodLabel renders the verify method behind the covering component. +func MethodLabel(t TargetStatus) string { + if t.Durability == nil || t.Durability.Method == "" { + return "—" + } + return t.Durability.Method +} + +// EvidenceLabel renders the evidence age, and — when the volume sets +// offload_max_evidence_age — the age against that budget, flagging stale +// evidence loudly. +func EvidenceLabel(t TargetStatus) string { + d := t.Durability + if d == nil || !d.LocalContent { + return "—" + } + age := "unknown" + if d.EvidenceAge != nil { + age = HumanAge(*d.EvidenceAge) + } + if d.MaxEvidenceAge > 0 { + age = fmt.Sprintf("%s / %s", age, HumanAge(d.MaxEvidenceAge)) + } + if d.Stale { + age += " STALE" + } + return age +} + +// OffloadLabel renders the "N offloadable now" decision-support line +// (friction-log F17) or a note that the volume has no offload policy. +func OffloadLabel(o OffloadReadiness) string { + if !o.Applicable { + return "offload: no policy" + } + return fmt.Sprintf("offloadable now: %s (%d files) of %s present (%d files)", + HumanBytes(o.OffloadableBytes), o.OffloadableFiles, + HumanBytes(o.PresentBytes), o.PresentFiles) +} + +// TrafficLight maps a level to the green/amber/red word the CLI summary +// line and the surfaces' headers use. Neutral reads as green — no problem +// to report. +func TrafficLight(l Level) string { + switch l { + case LevelWarn: + return "amber" + case LevelCritical: + return "red" + default: + return "green" + } +} diff --git a/status/logic_test.go b/status/logic_test.go new file mode 100644 index 0000000..09c3ab2 --- /dev/null +++ b/status/logic_test.go @@ -0,0 +1,184 @@ +package status + +import ( + "database/sql" + "testing" + "time" + + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/store" +) + +func TestCadenceLevel(t *testing.T) { + h := time.Hour + cases := []struct { + age time.Duration + cadence time.Duration + want Level + }{ + {30 * time.Minute, h, LevelOK}, // within cadence + {h, h, LevelOK}, // exactly at cadence + {2 * h, h, LevelWarn}, // late, within lateFactor + {lateFactor * h, h, LevelWarn}, // at the boundary + {lateFactor*h + time.Minute, h, LevelCritical}, // beyond lateFactor + {1000 * h, 0, LevelOK}, // no cadence: any age is fine + } + for _, c := range cases { + if got := cadenceLevel(c.age, c.cadence); got != c.want { + t.Errorf("cadenceLevel(%s, %s) = %v, want %v", c.age, c.cadence, got, c.want) + } + } +} + +func refusedRun(errMsg string) *store.Run { + return &store.Run{Status: store.RunStatusRefused, Error: sql.NullString{String: errMsg, Valid: errMsg != ""}} +} + +func TestClassifyStanding(t *testing.T) { + bootstrapErr := "destination has no marker — re-run with --init to bootstrap" + cases := []struct { + name string + lastTerm *store.Run + hadSuccess bool + want Standing + }{ + {"no runs", nil, false, StandingNone}, + {"fresh bootstrap refusal", refusedRun(bootstrapErr), false, StandingNeedsBootstrap}, + {"refusal after prior success", refusedRun(bootstrapErr), true, StandingRefused}, + {"non-bootstrap refusal", refusedRun("layout guard: history is not packed"), false, StandingRefused}, + {"success", &store.Run{Status: store.RunStatusSuccess}, true, StandingNone}, + } + for _, c := range cases { + if got := classifyStanding(c.lastTerm, c.hadSuccess); got != c.want { + t.Errorf("%s: classifyStanding = %v, want %v", c.name, got, c.want) + } + } +} + +func TestDurabilityLevel(t *testing.T) { + dur := func(local, covered, stale bool) *Durability { + return &Durability{LocalContent: local, Covered: covered, Stale: stale} + } + cases := []struct { + name string + d *Durability + required bool + want Level + }{ + {"no local content", dur(false, false, false), true, LevelNeutral}, + {"required covered fresh", dur(true, true, false), true, LevelOK}, + {"required uncovered", dur(true, false, false), true, LevelWarn}, + {"required covered stale", dur(true, true, true), true, LevelWarn}, + {"non-required covered", dur(true, true, false), false, LevelOK}, + {"non-required uncovered", dur(true, false, false), false, LevelNeutral}, + } + for _, c := range cases { + if got := durabilityLevel(c.d, c.required); got != c.want { + t.Errorf("%s: durabilityLevel = %v, want %v", c.name, got, c.want) + } + } +} + +func TestEvidenceStale(t *testing.T) { + age := func(d time.Duration) *time.Duration { return &d } + cases := []struct { + name string + d *Durability + want bool + }{ + {"no policy", &Durability{MaxEvidenceAge: 0, EvidenceAge: age(time.Hour)}, false}, + {"unknown age fail-closed", &Durability{MaxEvidenceAge: time.Hour, EvidenceAge: nil}, true}, + {"fresh", &Durability{MaxEvidenceAge: time.Hour, EvidenceAge: age(30 * time.Minute)}, false}, + {"aged out", &Durability{MaxEvidenceAge: time.Hour, EvidenceAge: age(2 * time.Hour)}, true}, + } + for _, c := range cases { + if got := evidenceStale(c.d); got != c.want { + t.Errorf("%s: evidenceStale = %v, want %v", c.name, got, c.want) + } + } +} + +func TestSyncLevelTerminalOutcome(t *testing.T) { + age := func(d time.Duration) *time.Duration { return &d } + // A fresh success would be OK, but a more-recent failed/partial terminal + // run must not be masked by that freshness. + fresh := TargetStatus{SyncTarget: true, LastSyncAgo: age(time.Minute), Cadence: time.Hour} + cases := []struct { + name string + terminal *store.Run + want Level + }{ + {"failed outranks fresh success", &store.Run{Status: store.RunStatusFailed}, LevelCritical}, + {"partial is at least amber", &store.Run{Status: store.RunStatusPartial}, LevelWarn}, + {"success uses freshness", &store.Run{Status: store.RunStatusSuccess}, LevelOK}, + } + for _, c := range cases { + if got := syncLevel(fresh, c.terminal); got != c.want { + t.Errorf("%s: syncLevel = %v, want %v", c.name, got, c.want) + } + } +} + +func TestLevelExitCode(t *testing.T) { + cases := map[Level]int{LevelNeutral: 0, LevelOK: 0, LevelWarn: 1, LevelCritical: 2} + for l, want := range cases { + if got := l.ExitCode(); got != want { + t.Errorf("%v.ExitCode() = %d, want %d", l, got, want) + } + } +} + +func TestHumanBytes(t *testing.T) { + cases := map[int64]string{ + 0: "0 B", + 512: "512 B", + 1024: "1.0 KiB", + 1536: "1.5 KiB", + 1024 * 1024: "1.0 MiB", + 3 * 1024 * 1024 * 1024: "3.0 GiB", + } + for n, want := range cases { + if got := HumanBytes(n); got != want { + t.Errorf("HumanBytes(%d) = %q, want %q", n, got, want) + } + } +} + +func TestOrderedTargets(t *testing.T) { + // sync_to first in declared order, then offload_requires entries not + // already listed, deduplicated ("nas" appears in both). + vol := &config.Volume{SyncTo: []string{"nas"}, OffloadRequires: []string{"nas", "s3archive"}} + got := orderedTargets(vol) + want := []string{"nas", "s3archive"} + if len(got) != len(want) { + t.Fatalf("orderedTargets = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("orderedTargets = %v, want %v", got, want) + } + } +} + +func TestStateLabel(t *testing.T) { + age := func(d time.Duration) *time.Duration { return &d } + cases := []struct { + name string + t TargetStatus + want string + }{ + {"alarm", TargetStatus{Standing: StandingAlarm}, "alarm"}, + {"needs-init", TargetStatus{Standing: StandingNeedsBootstrap}, "needs-init"}, + {"failed", TargetStatus{LastOutcome: "failed", SyncTarget: true}, "failed"}, + {"partial", TargetStatus{LastOutcome: "partial", SyncTarget: true, LastSyncAgo: age(time.Minute), SyncLevel: LevelWarn}, "partial"}, + {"relayed", TargetStatus{SyncTarget: false, Required: true}, "—"}, + {"never", TargetStatus{SyncTarget: true}, "never-synced"}, + {"ok", TargetStatus{SyncTarget: true, LastSyncAgo: age(time.Minute), SyncLevel: LevelOK}, "ok"}, + {"late", TargetStatus{SyncTarget: true, LastSyncAgo: age(time.Hour), SyncLevel: LevelWarn}, "late"}, + } + for _, c := range cases { + if got := StateLabel(c.t); got != c.want { + t.Errorf("%s: StateLabel = %q, want %q", c.name, got, c.want) + } + } +} diff --git a/status/runs.go b/status/runs.go new file mode 100644 index 0000000..2883637 --- /dev/null +++ b/status/runs.go @@ -0,0 +1,101 @@ +package status + +import ( + "context" + "database/sql" + "time" + + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/store" +) + +// latestSuccessfulSync returns the most recent fully-landed sync of the +// pair, or nil when there is none. sql.ErrNoRows is the expected "never +// synced" signal and maps to nil; any other error propagates. +func latestSuccessfulSync(ctx context.Context, s *store.Store, volID int64, name string) (*store.Run, error) { + run, err := s.LatestSuccessfulSyncRun(ctx, volID, name) + if store.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &run, nil +} + +// latestTerminalSync returns the pair's most recent terminal sync run of +// any outcome (success, partial, failed, refused), or nil when there is +// none. It is the basis for the standing-state classification: a refused +// or failed latest run outranks an older success. +func latestTerminalSync(ctx context.Context, s *store.Store, volID int64, name string) (*store.Run, error) { + run, err := s.LatestFinishedRun(ctx, store.RunKindSync, volID, name) + if store.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &run, nil +} + +// lastIndexAge returns the age of the volume's most recent successful (or +// partial) index run, or nil when it has never been indexed. +func lastIndexAge(ctx context.Context, s *store.Store, volID int64, now time.Time) (*time.Duration, error) { + run, err := s.LatestSuccessfulIndexRun(ctx, volID) + if store.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return ageOf(&run, now), nil +} + +// ageOf returns how long ago a run ended, or nil for a nil run or one that +// never recorded an end. Negative ages (clock skew) clamp to zero so a +// surface never renders "-3s ago". +func ageOf(run *store.Run, now time.Time) *time.Duration { + if run == nil || !run.EndedAtNs.Valid { + return nil + } + d := now.Sub(time.Unix(0, run.EndedAtNs.Int64)) + if d < 0 { + d = 0 + } + return &d +} + +// evidenceAge returns how long ago a durability component was last +// verified, or nil when the instant is unknown (NULL verified_at_ns). +// Clamped at zero for clock skew, matching ageOf. +func evidenceAge(verifiedAtNs sql.NullInt64, now time.Time) *time.Duration { + if !verifiedAtNs.Valid { + return nil + } + d := now.Sub(time.Unix(0, verifiedAtNs.Int64)) + if d < 0 { + d = 0 + } + return &d +} + +// targetKind classifies a target name against this node's config. +func targetKind(cfg *config.Config, name string) TargetKind { + if _, ok := cfg.Nodes[name]; ok { + return KindNode + } + if _, ok := cfg.Destinations[name]; ok { + return KindDestination + } + return KindRelayed +} + +// contains reports whether name appears in names. +func contains(names []string, name string) bool { + for _, n := range names { + if n == name { + return true + } + } + return false +} diff --git a/status/status.go b/status/status.go new file mode 100644 index 0000000..d6b7f83 --- /dev/null +++ b/status/status.go @@ -0,0 +1,285 @@ +// Package status is squirrel's shared "am I safe?" query layer. It reads +// the local index and config and produces, per configured volume and per +// (volume × target), the coverage and durability facts both the CLI +// `squirrel status` command and the TUI dashboard render — friction-log +// F16 (per-target sync coverage), F17 and F23 (durability and offload +// readiness). It is read-only introspection (ux-principle 2): it opens no +// run, reads no disk bytes, and mutates nothing. +// +// The package returns data and a severity per cell; it does not format for +// any particular surface beyond the neutral HumanBytes helper. Each +// consumer renders the Report its own way while sharing one source of +// truth for the facts and their severities. +package status + +import ( + "fmt" + "time" +) + +// Level is the "am I safe?" severity of a cell, a volume, or the whole +// report. It is ordered so the worst of a set is its maximum, and it maps +// directly onto the CLI's scriptable exit code via ExitCode. +type Level int + +const ( + // LevelNeutral is an informational state that says nothing about + // safety: a relayed target this node never pushes to, a durability + // figure for a volume with no offload policy, content that does not + // originate locally. It never escalates the worst-of aggregation and + // contributes exit code 0. + LevelNeutral Level = iota + // LevelOK is an affirmatively-healthy state — caught up within cadence, + // durable and freshly verified. "You may close the laptop." + LevelOK + // LevelWarn is amber: not caught up yet, needs a one-time bootstrap, + // local content not yet durable on a required target, evidence aged + // past the policy. Recoverable and expected, not a failure. + LevelWarn + // LevelCritical is red: a latched alarm, a refused sync that regressed + // from a prior success, a failed transfer, or a pair far past its + // cadence. Something is wrong and needs attention. + LevelCritical +) + +// String renders a Level as a short lowercase word for CLI output and +// tests. It is deliberately stable — scripts and golden tests key on it. +func (l Level) String() string { + switch l { + case LevelNeutral: + return "neutral" + case LevelOK: + return "ok" + case LevelWarn: + return "amber" + case LevelCritical: + return "red" + default: + return "unknown" + } +} + +// ExitCode maps a Level onto the process exit status `squirrel status` +// returns: 0 for green/neutral, 1 for amber, 2 for red. Neutral and OK +// share 0 because neither reports a problem to a script. +func (l Level) ExitCode() int { + switch l { + case LevelWarn: + return 1 + case LevelCritical: + return 2 + default: + return 0 + } +} + +// worst returns the higher-severity of two levels. +func worst(a, b Level) Level { + if b > a { + return b + } + return a +} + +// Standing is a per-target standing state carried over from #157: an +// abnormal condition that persists across cadence ticks until resolved, +// rather than a single run's outcome. +type Standing int + +const ( + // StandingNone means no standing condition — the target's health is + // whatever its latest sync freshness says. + StandingNone Standing = iota + // StandingNeedsBootstrap is a fresh destination awaiting its one-time + // `sync --init`: the latest run refused with a bootstrap reason and the + // pair has never had a successful sync. Amber — an expected human step, + // not a fault (F10). + StandingNeedsBootstrap + // StandingRefused is a preflight safety gate declining after the pair + // had previously succeeded (a marker gone missing, a layout guard) — + // the destination broke. Red (F26). + StandingRefused + // StandingAlarm is a latched verify mismatch on the destination (F30). + // Red until an operator clears it. + StandingAlarm +) + +// String renders a Standing as a short word for display and tests. +func (s Standing) String() string { + switch s { + case StandingNeedsBootstrap: + return "needs-init" + case StandingRefused: + return "refused" + case StandingAlarm: + return "alarm" + default: + return "ok" + } +} + +// TargetKind classifies a target name against this node's config: a local +// destination, a peer node, or a relayed target named only in +// offload_requires (evidence for it arrives via a peer durability pull; +// this node has no local config for it). +type TargetKind string + +const ( + KindDestination TargetKind = "destination" + KindNode TargetKind = "node" + KindRelayed TargetKind = "relayed" +) + +// HumanBytes renders a byte count in binary units with one decimal place +// above KiB, for the "N GB offloadable now" figure both surfaces show. It +// is the one formatting helper the library exposes, because the two +// consumers must render identical numbers. +func HumanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// Report is the whole snapshot for one node: every configured volume with +// its per-target coverage and durability. Now is the instant every age and +// staleness judgement in the report was taken against, so a consumer +// renders one coherent moment. +type Report struct { + Now time.Time + Volumes []VolumeStatus +} + +// Level is the worst level across every volume — the report's single +// "am I safe?" answer and the basis for the CLI exit code. +func (r Report) Level() Level { + l := LevelNeutral + for _, v := range r.Volumes { + l = worst(l, v.Level()) + } + return l +} + +// VolumeStatus is one configured volume's coverage and durability. +type VolumeStatus struct { + Name string + Path string + // Indexed is true when the volume has a row in the local index (it has + // been indexed at least once). A configured-but-never-indexed volume is + // Indexed=false with no targets carrying run history — the F4 "a freshly + // configured machine reports nothing" gap, surfaced as amber rather than + // a blank screen. + Indexed bool + // LastIndexAgo is the age of the most recent successful index run, or + // nil when the volume has never been indexed. + LastIndexAgo *time.Duration + Targets []TargetStatus + // Offload is the volume's offload-readiness tally ("N GB offloadable + // now"). Offload.Applicable is false for a volume with no offload + // policy. + Offload OffloadReadiness +} + +// Level is the worst level across the volume's targets, escalated to amber +// when the volume has never been indexed. +func (v VolumeStatus) Level() Level { + l := LevelNeutral + if !v.Indexed { + l = LevelWarn + } + for _, t := range v.Targets { + l = worst(l, t.Level()) + } + return l +} + +// TargetStatus is one (volume × target) cell: is this target caught up, +// and is the volume's local content durable on it? +type TargetStatus struct { + Name string + Kind TargetKind + // SyncTarget is true when the target is in the volume's sync_to (this + // node pushes to it). Required is true when it is in offload_requires + // (its durability gates offload). A relayed target is Required but not + // a SyncTarget. + SyncTarget bool + Required bool + // Cadence is the volume's sync_every; lateness of the last sync is + // judged relative to it, not a global constant. Zero means no scheduled + // cadence, so a successful sync of any age reads as OK. + Cadence time.Duration + // LastSyncAgo is the age of the most recent successful sync to this + // target, or nil when this node has never successfully synced it (or + // never pushes to it — a relayed target). + LastSyncAgo *time.Duration + // Standing is the target's standing state (alarm / refused / + // needs-bootstrap / none). + Standing Standing + // AlarmDetail is the latched alarm's detail text when Standing is + // StandingAlarm, for the surface to show what tripped. + AlarmDetail string + // LastOutcome is the status of the pair's most recent terminal sync run + // (success / partial / failed / refused / aborted), or "" when this + // node has never run a sync against the target. It lets a surface name + // a failed transfer precisely, distinct from an aged-out success. + LastOutcome string + // SyncLevel is the severity of the coverage dimension alone (freshness + // and standing state); durability is scored separately in Durability. + SyncLevel Level + // Durability is the local-content durability on this target, or nil when + // durability is not tracked for the pair (a non-required target with no + // recorded vector). + Durability *Durability +} + +// Level is the worst of the target's coverage and durability dimensions. +func (t TargetStatus) Level() Level { + l := t.SyncLevel + if t.Durability != nil { + l = worst(l, t.Durability.Level) + } + return l +} + +// Durability is the local-content durability of a volume on one target. +type Durability struct { + // LocalContent is true when this node originates present content in the + // volume. When false, "is my local content durable here" is vacuously + // satisfied and Level is neutral — the hub, whose photos originate on + // the edges, sees this for its inbound volumes. + LocalContent bool + // Covered is true when the target's durability vector covers this + // node's highest local origin run — every locally-originated present + // content is recorded durable there. + Covered bool + // Method is the verify method behind the covering component (blake3, + // peer-blake3, kopia-verify, presence+size, size+mtime), or "" when + // there is no component. + Method string + // EvidenceAge is how long ago the covering component was last verified, + // or nil when unknown (no component, or a component that never carried + // a verification instant). + EvidenceAge *time.Duration + // MaxEvidenceAge is the volume's offload_max_evidence_age; zero means no + // staleness policy. Stale is true when the policy is set and the + // evidence is older than it (or its age is unknown). + MaxEvidenceAge time.Duration + Stale bool + Level Level +} + +// OffloadReadiness is a volume's "what could I offload right now" tally. +type OffloadReadiness struct { + // Applicable is false when the volume declares no offload policy. + Applicable bool + OffloadableFiles int + OffloadableBytes int64 + PresentFiles int + PresentBytes int64 +} diff --git a/tui/dashboard.go b/tui/dashboard.go index 30bac28..862a183 100644 --- a/tui/dashboard.go +++ b/tui/dashboard.go @@ -10,20 +10,25 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mbertschler/squirrel/config" + "github.com/mbertschler/squirrel/status" "github.com/mbertschler/squirrel/store" ) // dashboardModel surfaces squirrel's live state in one screen: // // - the local agent's health (one-line probe of /v1/health) +// - standing per-destination alarms // - runs currently in-flight (kind, volume, destination, elapsed) -// - per-volume health (name, path, last-index, last-sync) +// - the per-(volume × target) coverage + durability grid (the "am I +// safe?" panel, from the shared status query layer) // - the most recent terminal runs // // Data is pulled on each tickMsg via a single SQL pass plus one HTTP probe; // both run as Bubble Tea commands so the UI never blocks on I/O. type dashboardModel struct { store *store.Store + cfg *config.Config client *agentClient width, height int @@ -32,8 +37,22 @@ type dashboardModel struct { loaded bool loadErr error agentStatus agentStatus + + // readinessByVol caches the last computed offload-readiness tally per + // volume name. The coverage/durability grid rebuilds every one-second + // tick with readiness skipped (its dominant cost — a whole-index gate + // pass); the tally is refreshed on the slower readinessRefreshTicks + // cadence and overlaid onto the grid at render time, so "N offloadable + // now" stays visible without paying for it every tick. + readinessByVol map[string]status.OffloadReadiness + ticksSinceReadiness int } +// readinessRefreshTicks is how many one-second ticks pass between +// offload-readiness recomputations. Coverage and durability still refresh +// every tick; only the expensive gate-pass tally is throttled. +const readinessRefreshTicks = 15 + // dashboardData is the snapshot rendered by the dashboard. Built fresh on // each tick — there is no incremental update path, since the queries are // trivial and the resulting struct is small. @@ -42,10 +61,11 @@ type dashboardData struct { volumes []store.Volume activeRuns []store.Run recentRuns []store.Run - // latestByVol[volID][kind] is the most recent terminal-status run for - // that (volume, kind) pair. Used to fill the "last index" / "last sync" - // columns of the volumes table. - latestByVol map[int64]map[string]store.Run + // coverage is the per-(volume × target) sync-coverage and durability + // grid from the shared status query layer — the same facts and + // severities `squirrel status` prints. Empty when no config is loaded + // (the grid needs sync_to / offload_requires / cadences to render). + coverage status.Report // alarms are the standing per-destination alarms (#157, F30). A verify // mismatch latches one until cleared; the dashboard shows them so the // trust surface answers "am I safe?" with a red panel, not silence. @@ -55,6 +75,10 @@ type dashboardData struct { type dashboardDataMsg struct { data dashboardData err error + // readinessFresh is true when this fetch recomputed the offload tally + // (a slow-cadence tick), so the receiver should refresh its cache from + // data.coverage; a fast tick leaves it false and the cache stands. + readinessFresh bool } type agentStatus struct { @@ -65,8 +89,8 @@ type agentStatus struct { type agentStatusMsg agentStatus -func newDashboardModel(s *store.Store) *dashboardModel { - return &dashboardModel{store: s} +func newDashboardModel(s *store.Store, cfg *config.Config) *dashboardModel { + return &dashboardModel{store: s, cfg: cfg} } // attachClient is called by the root model after construction so the @@ -75,7 +99,10 @@ func newDashboardModel(s *store.Store) *dashboardModel { func (m *dashboardModel) attachClient(c *agentClient) { m.client = c } func (m *dashboardModel) Init() tea.Cmd { - return tea.Batch(m.fetchData(), m.probeAgent()) + // Recompute readiness on (re)activation so the tally is current the + // moment the user opens the dashboard, then throttle on the tick path. + m.ticksSinceReadiness = 0 + return tea.Batch(m.fetchData(true), m.probeAgent()) } func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -84,12 +111,20 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height return m, nil case tickMsg: - return m, tea.Batch(m.fetchData(), m.probeAgent()) + m.ticksSinceReadiness++ + refresh := m.ticksSinceReadiness >= readinessRefreshTicks + if refresh { + m.ticksSinceReadiness = 0 + } + return m, tea.Batch(m.fetchData(refresh), m.probeAgent()) case dashboardDataMsg: m.loaded = true m.loadErr = msg.err if msg.err == nil { m.data = msg.data + if msg.readinessFresh { + m.cacheReadiness(msg.data.coverage) + } } return m, nil case agentStatusMsg: @@ -114,7 +149,7 @@ func (m *dashboardModel) View() string { } sections = append(sections, m.renderActiveRuns(), - m.renderVolumes(), + m.renderCoverage(), m.renderRecentRuns(), ) return strings.Join(sections, "\n\n") @@ -183,21 +218,92 @@ func (m *dashboardModel) renderActiveRuns() string { return header + "\n" + renderTable(rows, []lipgloss.Color{"", "", "", "", colourRunning}) } -func (m *dashboardModel) renderVolumes() string { - header := styleHeader.Render(fmt.Sprintf("Volumes (%d)", len(m.data.volumes))) - if len(m.data.volumes) == 0 { - return header + "\n" + styleMuted.Render("no volumes configured") +// renderCoverage is the "am I safe?" panel: per volume, the per-target +// sync-coverage and durability grid from the shared status layer, replacing +// the old single LAST SYNC cell that hid a week-behind target behind a +// fresh ✓ (friction-log F16/F17/F23). Each volume gets a header line +// (name, path, index freshness, offloadable total) coloured by its worst +// level, then a target sub-table with the STATE and DURABLE cells coloured +// per target. +func (m *dashboardModel) renderCoverage() string { + vols := m.data.coverage.Volumes + header := styleHeader.Render(fmt.Sprintf("Coverage (%d)", len(vols))) + if len(vols) == 0 { + hint := "no volumes configured" + if m.cfg == nil { + hint = "no config loaded — coverage needs sync_to / offload_requires to render" + } + return header + "\n" + styleMuted.Render(hint) } - rows := [][]string{{"NAME", "PATH", "LAST INDEX", "LAST SYNC"}} - for _, v := range m.data.volumes { + blocks := []string{header} + for _, v := range vols { + blocks = append(blocks, m.renderVolumeCoverage(v)) + } + return strings.Join(blocks, "\n\n") +} + +// renderVolumeCoverage renders one volume's coverage block. The +// offload-readiness figure comes from the throttled cache (the grid itself +// rebuilds every tick with readiness skipped), falling back to the volume's +// own tally when the cache has no entry yet. +func (m *dashboardModel) renderVolumeCoverage(v status.VolumeStatus) string { + dot := lipgloss.NewStyle().Foreground(levelColour(v.Level())).Render("●") + title := fmt.Sprintf("%s %s %s", dot, v.Name, styleMuted.Render(v.Path)) + offload := v.Offload + if cached, ok := m.readinessByVol[v.Name]; ok { + offload = cached + } + meta := styleMuted.Render(fmt.Sprintf("index %s · %s", + status.IndexLabel(v), status.OffloadLabel(offload))) + if len(v.Targets) == 0 { + return title + "\n" + meta + "\n" + styleMuted.Render(" no targets configured") + } + rows := [][]string{{"TARGET", "ROLE", "LAST SYNC", "STATE", "DURABLE", "METHOD", "EVIDENCE"}} + for _, t := range v.Targets { rows = append(rows, []string{ - v.Name, - v.Path, - m.formatLast(v.ID, store.RunKindIndex), - m.formatLast(v.ID, store.RunKindSync), + t.Name, status.RoleLabel(t), status.LastSyncLabel(t), status.StateLabel(t), + status.DurableLabel(t), status.MethodLabel(t), status.EvidenceLabel(t), }) } - return header + "\n" + renderTable(rows, nil) + tbl := renderTableColoured(rows, nil, coverageCellColour(v.Targets)) + return title + "\n" + meta + "\n" + tbl +} + +// coverageCellColour paints the STATE column by each target's coverage +// level and the DURABLE column by its durability level, so the two "am I +// safe?" dimensions read at a glance without decoding the words. +func coverageCellColour(targets []status.TargetStatus) func(rowIdx, colIdx int) lipgloss.Color { + const stateCol, durableCol = 3, 4 + return func(rowIdx, colIdx int) lipgloss.Color { + if rowIdx == 0 || rowIdx > len(targets) { + return "" + } + t := targets[rowIdx-1] + switch colIdx { + case stateCol: + return levelColour(t.SyncLevel) + case durableCol: + if t.Durability != nil { + return levelColour(t.Durability.Level) + } + } + return "" + } +} + +// levelColour maps a status level onto the dashboard palette. Neutral gets +// no colour (the default foreground) so informational cells don't shout. +func levelColour(l status.Level) lipgloss.Color { + switch l { + case status.LevelOK: + return colourSuccess + case status.LevelWarn: + return colourWarning + case status.LevelCritical: + return colourFailure + default: + return "" + } } func (m *dashboardModel) renderRecentRuns() string { @@ -239,28 +345,29 @@ func (m *dashboardModel) volumeName(id sql.NullInt64) string { return fmt.Sprintf("vol#%d", id.Int64) } -func (m *dashboardModel) formatLast(volID int64, kind string) string { - byKind := m.data.latestByVol[volID] - if byKind == nil { - return styleMuted.Render("—") - } - r, ok := byKind[kind] - if !ok { - return styleMuted.Render("—") - } - ago := whenAgo(r.EndedAtNs, m.data.now) - statusGlyph := lipgloss.NewStyle().Foreground(statusColour(r.Status)).Render(glyphForStatus(r.Status)) - return fmt.Sprintf("%s %s", ago, statusGlyph) -} - -func (m *dashboardModel) fetchData() tea.Cmd { +// fetchData loads the dashboard snapshot. withReadiness selects whether the +// coverage build pays for the offload-readiness tally: false on the +// one-second tick (the grid still refreshes; the cached tally is overlaid +// at render), true on the slow cadence and on activation. +func (m *dashboardModel) fetchData(withReadiness bool) tea.Cmd { return func() tea.Msg { // Use a tight per-fetch deadline so a stuck DB doesn't freeze the UI. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - data, err := loadDashboardData(ctx, m.store) - return dashboardDataMsg{data: data, err: err} + data, err := loadDashboardData(ctx, m.store, m.cfg, !withReadiness) + return dashboardDataMsg{data: data, err: err, readinessFresh: withReadiness && err == nil} + } +} + +// cacheReadiness refreshes the per-volume offload tally from a report that +// was built with readiness computed. Fast-tick reports carry only the +// policy flag (zero counts) and must not overwrite this cache. +func (m *dashboardModel) cacheReadiness(rep status.Report) { + cache := make(map[string]status.OffloadReadiness, len(rep.Volumes)) + for _, v := range rep.Volumes { + cache[v.Name] = v.Offload } + m.readinessByVol = cache } func (m *dashboardModel) probeAgent() tea.Cmd { @@ -279,13 +386,16 @@ func (m *dashboardModel) probeAgent() tea.Cmd { } } -// loadDashboardData runs the SQL queries that back the dashboard. The +// loadDashboardData runs the queries that back the dashboard. The // recent-runs bucket comes from a bounded ListRuns scan (200 is plenty -// for "what happened today"); the per-(volume,kind) "last successful" -// table comes from its own helper that scans every run, so volumes -// whose last index sits beyond the recent window still surface -// correctly. -func loadDashboardData(ctx context.Context, s *store.Store) (dashboardData, error) { +// for "what happened today"); the coverage grid comes from the shared +// status query layer, which scans per (volume × target) so a target beyond +// the recent-runs window still surfaces correctly. The coverage build is +// skipped when no config is loaded — it needs sync_to / offload_requires / +// cadences — leaving the grid to render its own "no config" hint. +// skipReadiness omits the expensive offload-readiness tally (see fetchData +// and readinessRefreshTicks). +func loadDashboardData(ctx context.Context, s *store.Store, cfg *config.Config, skipReadiness bool) (dashboardData, error) { now := time.Now() vols, err := s.ListVolumes(ctx) if err != nil { @@ -295,14 +405,16 @@ func loadDashboardData(ctx context.Context, s *store.Store) (dashboardData, erro if err != nil { return dashboardData{}, fmt.Errorf("list runs: %w", err) } - latestByVol, err := s.LatestSuccessfulRunsByVolumeAndKind(ctx) - if err != nil { - return dashboardData{}, fmt.Errorf("latest by volume: %w", err) - } alarms, err := s.ListDestinationAlarms(ctx) if err != nil { return dashboardData{}, fmt.Errorf("list alarms: %w", err) } + var coverage status.Report + if cfg != nil { + if coverage, err = status.BuildWithOptions(ctx, s, cfg, status.Options{SkipOffloadReadiness: skipReadiness}); err != nil { + return dashboardData{}, fmt.Errorf("build coverage: %w", err) + } + } var active, recent []store.Run for _, r := range runs { if r.Status == store.RunStatusRunning { @@ -314,11 +426,11 @@ func loadDashboardData(ctx context.Context, s *store.Store) (dashboardData, erro } } return dashboardData{ - now: now, - volumes: vols, - activeRuns: active, - recentRuns: recent, - latestByVol: latestByVol, - alarms: alarms, + now: now, + volumes: vols, + activeRuns: active, + recentRuns: recent, + coverage: coverage, + alarms: alarms, }, nil } diff --git a/tui/dashboard_test.go b/tui/dashboard_test.go index c6f78b9..29bdea2 100644 --- a/tui/dashboard_test.go +++ b/tui/dashboard_test.go @@ -36,8 +36,6 @@ func TestLoadDashboardDataPartitionsRuns(t *testing.T) { // a successful sync run. We expect: // - activeRuns: the one running sync // - recentRuns: every terminal run, newest first, capped at 10 - // - latestByVol[vol.ID]: index = most recent success, sync = the - // successful one (failed audit doesn't establish "last audit") finishedIdx1, _ := s.BeginIndexRun(ctx, store.RunKindIndex, vol.ID, false) if err := s.FinishRun(ctx, finishedIdx1, store.RunStatusSuccess, "", 100); err != nil { t.Fatalf("FinishRun: %v", err) @@ -57,7 +55,10 @@ func TestLoadDashboardDataPartitionsRuns(t *testing.T) { t.Fatalf("FinishRun: %v", err) } - data, err := loadDashboardData(ctx, s) + // cfg is nil here: this test pins the run-partitioning behaviour, which + // is independent of the config-driven coverage grid (that is exercised + // against the shared status layer in the status package's own tests). + data, err := loadDashboardData(ctx, s, nil, false) if err != nil { t.Fatalf("loadDashboardData: %v", err) } @@ -80,18 +81,8 @@ func TestLoadDashboardDataPartitionsRuns(t *testing.T) { t.Errorf("recentRuns[0] = %d, want %d (most recent)", data.recentRuns[0].ID, successSync) } - byKind := data.latestByVol[vol.ID] - if byKind == nil { - t.Fatalf("latestByVol missing entry for vol#%d", vol.ID) - } - if byKind[store.RunKindIndex].ID != finishedIdx2 { - t.Errorf("latest index = %d, want %d (the partial-but-newer one)", - byKind[store.RunKindIndex].ID, finishedIdx2) - } - if byKind[store.RunKindSync].ID != successSync { - t.Errorf("latest sync = %d, want %d", byKind[store.RunKindSync].ID, successSync) - } - if _, present := byKind[store.RunKindAudit]; present { - t.Errorf("latestByVol must not include the failed audit run") + // With no config, the coverage grid renders nothing. + if len(data.coverage.Volumes) != 0 { + t.Errorf("coverage.Volumes = %d, want 0 without config", len(data.coverage.Volumes)) } } diff --git a/tui/tui.go b/tui/tui.go index b11d895..0ccfee6 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -90,7 +90,7 @@ type rootModel struct { func newRootModel(s *store.Store, cfg *config.Config) *rootModel { client := newAgentClient(cfg) - dash := newDashboardModel(s) + dash := newDashboardModel(s, cfg) dash.attachClient(client) return &rootModel{ active: screenDashboard,