f9718e6077
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m32s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
129 lines
3.7 KiB
Go
129 lines
3.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Snapshot mirrors the snapshots projection table.
|
|
type Snapshot struct {
|
|
ID string
|
|
HostID string
|
|
ShortID string
|
|
Time time.Time
|
|
Hostname string
|
|
Paths []string
|
|
Tags []string
|
|
SizeBytes int64
|
|
FileCount int64
|
|
RefreshedAt time.Time
|
|
}
|
|
|
|
// ReplaceHostSnapshots atomically replaces the snapshot projection for
|
|
// one host. Snapshots are reported by the agent in full after each
|
|
// successful backup, so we treat the message as the new source of
|
|
// truth and delete-then-insert under one transaction.
|
|
//
|
|
// snapshot_count on the host row is updated in the same tx so the
|
|
// dashboard's per-host count is always consistent with the snapshot
|
|
// list the host detail page renders.
|
|
func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps []Snapshot, when time.Time) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("store: begin snapshots tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM snapshots WHERE host_id = ?`, hostID); err != nil {
|
|
return fmt.Errorf("store: clear snapshots for host: %w", err)
|
|
}
|
|
|
|
if len(snaps) > 0 {
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
`INSERT INTO snapshots (
|
|
id, host_id, short_id, time, hostname, paths, tags,
|
|
size_bytes, file_count, refreshed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return fmt.Errorf("store: prepare snapshot insert: %w", err)
|
|
}
|
|
defer func() { _ = stmt.Close() }()
|
|
|
|
refreshed := when.UTC().Format(time.RFC3339Nano)
|
|
for _, snap := range snaps {
|
|
paths, _ := json.Marshal(snap.Paths)
|
|
tags, _ := json.Marshal(snap.Tags)
|
|
if _, err := stmt.ExecContext(ctx,
|
|
snap.ID, hostID, snap.ShortID,
|
|
snap.Time.UTC().Format(time.RFC3339Nano),
|
|
snap.Hostname, string(paths), string(tags),
|
|
snap.SizeBytes, snap.FileCount, refreshed,
|
|
); err != nil {
|
|
return fmt.Errorf("store: insert snapshot %s: %w", snap.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE hosts SET snapshot_count = ?, snapshot_refreshed_at = ? WHERE id = ?`,
|
|
len(snaps), when.UTC().Format(time.RFC3339Nano), hostID); err != nil {
|
|
return fmt.Errorf("store: update host snapshot_count: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("store: commit snapshots: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListSnapshotsByHost returns the cached snapshot list for a host,
|
|
// most-recent first. Empty slice is a normal "no snapshots yet" case.
|
|
func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snapshot, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, host_id, short_id, time, hostname, paths, tags,
|
|
size_bytes, file_count, refreshed_at
|
|
FROM snapshots
|
|
WHERE host_id = ?
|
|
ORDER BY time DESC`, hostID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list snapshots: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var out []Snapshot
|
|
for rows.Next() {
|
|
snap, err := scanSnapshotRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *snap)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func scanSnapshotRow(r *sql.Rows) (*Snapshot, error) {
|
|
var (
|
|
snap Snapshot
|
|
t, refresh string
|
|
paths, tags string
|
|
)
|
|
if err := r.Scan(&snap.ID, &snap.HostID, &snap.ShortID,
|
|
&t, &snap.Hostname, &paths, &tags,
|
|
&snap.SizeBytes, &snap.FileCount, &refresh); err != nil {
|
|
return nil, fmt.Errorf("store: scan snapshot: %w", err)
|
|
}
|
|
snap.Time, _ = time.Parse(time.RFC3339Nano, t)
|
|
snap.RefreshedAt, _ = time.Parse(time.RFC3339Nano, refresh)
|
|
if paths != "" {
|
|
_ = json.Unmarshal([]byte(paths), &snap.Paths)
|
|
}
|
|
if tags != "" {
|
|
_ = json.Unmarshal([]byte(tags), &snap.Tags)
|
|
}
|
|
return &snap, nil
|
|
}
|