Files
steve 51192c3603 ui+store: dashboard polish — repo size projection + header alignment
- Project total_size_bytes onto hosts.repo_size_bytes inside the
  UpsertHostRepoStats transaction. The hosts row column has been
  unwritten since the initial schema in 0001, so the dashboard's
  Repo size cell has always rendered '—' even after backups. Now
  the column updates atomically alongside the host_repo_stats row,
  and FleetSummary's SUM(repo_size_bytes) becomes accurate too.
- Right-align the Alerts column header so it sits over its
  right-aligned value (was floating left of column, ambiguous).
- Add text-ink-mid to the 30d trend / Alerts / Tags headers so all
  column headers share the same brightness.
2026-05-07 22:55:21 +01:00

247 lines
7.4 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// HostRepoStats is the per-host projection of repo-level metrics.
// All pointer fields are nullable; nil means "not yet known." The row
// is created (or replaced) by UpsertHostRepoStats which merges in only
// the non-nil fields from a patch.
type HostRepoStats struct {
HostID string
TotalSizeBytes *int64
RawSizeBytes *int64
UniqueFiles *int64
SnapshotCount *int64
LastCheckAt *time.Time
LastCheckStatus string // "" | "ok" | "errors_found" | "failed"
LockPresent *bool
LastPruneAt *time.Time
LastPruneFreedBytes *int64
UpdatedAt time.Time
}
// GetHostRepoStats returns the row, or (nil, ErrNotFound) if absent.
func (s *Store) GetHostRepoStats(ctx context.Context, hostID string) (*HostRepoStats, error) {
row := s.db.QueryRowContext(ctx,
`SELECT host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at
FROM host_repo_stats WHERE host_id = ?`, hostID)
return scanHostRepoStats(row)
}
// getHostRepoStatsTx is identical to GetHostRepoStats but runs on an
// existing transaction so the fetch-merge-upsert in UpsertHostRepoStats
// is fully serialised.
func getHostRepoStatsTx(ctx context.Context, tx *sql.Tx, hostID string) (*HostRepoStats, error) {
row := tx.QueryRowContext(ctx,
`SELECT host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at
FROM host_repo_stats WHERE host_id = ?`, hostID)
return scanHostRepoStats(row)
}
// scanHostRepoStats scans one row from host_repo_stats.
func scanHostRepoStats(row *sql.Row) (*HostRepoStats, error) {
var (
st HostRepoStats
totalSize sql.NullInt64
rawSize sql.NullInt64
uniqueFiles sql.NullInt64
snapshotCount sql.NullInt64
lastCheckAt sql.NullString
lastCheckStatus sql.NullString
lockPresent int64
lastPruneAt sql.NullString
lastPruneFreed sql.NullInt64
updatedAt string
)
if err := row.Scan(
&st.HostID,
&totalSize, &rawSize, &uniqueFiles, &snapshotCount,
&lastCheckAt, &lastCheckStatus,
&lockPresent,
&lastPruneAt, &lastPruneFreed,
&updatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan host_repo_stats: %w", err)
}
if totalSize.Valid {
v := totalSize.Int64
st.TotalSizeBytes = &v
}
if rawSize.Valid {
v := rawSize.Int64
st.RawSizeBytes = &v
}
if uniqueFiles.Valid {
v := uniqueFiles.Int64
st.UniqueFiles = &v
}
if snapshotCount.Valid {
v := snapshotCount.Int64
st.SnapshotCount = &v
}
if lastCheckAt.Valid {
t, err := time.Parse(time.RFC3339Nano, lastCheckAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse last_check_at: %w", err)
}
st.LastCheckAt = &t
}
if lastCheckStatus.Valid {
st.LastCheckStatus = lastCheckStatus.String
}
lp := lockPresent != 0
st.LockPresent = &lp
if lastPruneAt.Valid {
t, err := time.Parse(time.RFC3339Nano, lastPruneAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse last_prune_at: %w", err)
}
st.LastPruneAt = &t
}
if lastPruneFreed.Valid {
v := lastPruneFreed.Int64
st.LastPruneFreedBytes = &v
}
t, err := time.Parse(time.RFC3339Nano, updatedAt)
if err != nil {
return nil, fmt.Errorf("store: parse host_repo_stats.updated_at: %w", err)
}
st.UpdatedAt = t
return &st, nil
}
// UpsertHostRepoStats writes a partial update — only non-nil pointer
// fields (and LastCheckStatus when non-empty) overwrite existing
// columns. Wrapped in a transaction so concurrent upserts on the same
// host don't lose updates.
func (s *Store) UpsertHostRepoStats(ctx context.Context, hostID string, patch HostRepoStats) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin host_repo_stats tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Fetch existing row; start from zero if absent.
cur, err := getHostRepoStatsTx(ctx, tx, hostID)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if cur == nil {
cur = &HostRepoStats{HostID: hostID}
}
// Merge: non-nil patch fields overwrite current.
if patch.TotalSizeBytes != nil {
cur.TotalSizeBytes = patch.TotalSizeBytes
}
if patch.RawSizeBytes != nil {
cur.RawSizeBytes = patch.RawSizeBytes
}
if patch.UniqueFiles != nil {
cur.UniqueFiles = patch.UniqueFiles
}
if patch.SnapshotCount != nil {
cur.SnapshotCount = patch.SnapshotCount
}
if patch.LastCheckAt != nil {
cur.LastCheckAt = patch.LastCheckAt
}
if patch.LastCheckStatus != "" {
cur.LastCheckStatus = patch.LastCheckStatus
}
if patch.LockPresent != nil {
cur.LockPresent = patch.LockPresent
}
if patch.LastPruneAt != nil {
cur.LastPruneAt = patch.LastPruneAt
}
if patch.LastPruneFreedBytes != nil {
cur.LastPruneFreedBytes = patch.LastPruneFreedBytes
}
now := time.Now().UTC().Format(time.RFC3339Nano)
// Convert *bool → int for lock_present.
var lockPresentInt int64
if cur.LockPresent != nil && *cur.LockPresent {
lockPresentInt = 1
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO host_repo_stats
(host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
total_size_bytes = excluded.total_size_bytes,
raw_size_bytes = excluded.raw_size_bytes,
unique_files = excluded.unique_files,
snapshot_count = excluded.snapshot_count,
last_check_at = excluded.last_check_at,
last_check_status = excluded.last_check_status,
lock_present = excluded.lock_present,
last_prune_at = excluded.last_prune_at,
last_prune_freed_bytes = excluded.last_prune_freed_bytes,
updated_at = excluded.updated_at`,
hostID,
nullableInt64(cur.TotalSizeBytes),
nullableInt64(cur.RawSizeBytes),
nullableInt64(cur.UniqueFiles),
nullableInt64(cur.SnapshotCount),
nullableTime(cur.LastCheckAt),
nullableStr(cur.LastCheckStatus),
lockPresentInt,
nullableTime(cur.LastPruneAt),
nullableInt64(cur.LastPruneFreedBytes),
now,
); err != nil {
return fmt.Errorf("store: upsert host_repo_stats: %w", err)
}
// Project total_size_bytes onto the dashboard's host row so the
// "Repo size" column and FleetSummary.SUM(repo_size_bytes) stay in
// sync with the latest report. We only write a non-nil size — a
// patch that doesn't carry a size (e.g. a prune-only ack) leaves
// the prior row value alone.
if cur.TotalSizeBytes != nil {
if _, err = tx.ExecContext(ctx,
`UPDATE hosts SET repo_size_bytes = ? WHERE id = ?`,
*cur.TotalSizeBytes, hostID,
); err != nil {
return fmt.Errorf("store: project repo_size_bytes onto hosts row: %w", err)
}
}
return tx.Commit()
}
// nullableInt64 converts *int64 to a database/sql-compatible nullable value.
func nullableInt64(p *int64) any {
if p == nil {
return nil
}
return *p
}
// nullableTime converts *time.Time to an RFC3339Nano string or nil.
func nullableTime(p *time.Time) any {
if p == nil {
return nil
}
return p.UTC().Format(time.RFC3339Nano)
}