Files
cloud-ip-validator/internal/db/db.go
T
ayurishchevandClaude Sonnet 5.5 b7669c9e41 Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an
address enters an idle queue, takes everything submitted or re-checked while it
is open and is finalized when all its addresses are done; a re-check after that
opens a new run, so results of different runs never mix. check_runs,
run_results (one result per address and run, with the verdict and the expected
and stored check counts), subnets, run_id on ip_queue and checks. Existing data
is split into runs at pauses of more than an hour; ingress checks get the
validator that held the address (also at write time from now on).

Analytics (internal/analytics): figures computed from the stored checks of the
latest cycle of each address in the run, as facts next to the verdict: summary,
reasons of partial, data quality, subnets, targets and the subnet x target
matrix by check type, ingress by site, error classes, validators, and the
address lists behind the indicators and error classes. API: analytics runs,
report, lists (JSON or CSV), subnet list; run and subnet filters for the
registry.

Dashboard: /analytics matching the approved mockup (run selector, indicators
with address lists and CSV, error-class dialogs, drill-down to the registry),
subnet list on /settings. Sidebar: the control-api link state, theme toggle and
logout moved to the top, the three dots next to the logo removed, sections
grouped.

Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the
updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:36:03 +03:00

147 lines
4.0 KiB
Go

// Package db owns the SQLite connection, schema migrations, and all queries
// used by the Control API. It is the only package in the system that talks
// to the database directly — agents and probers never connect to it.
package db
import (
"context"
"database/sql"
_ "embed"
"fmt"
"time"
_ "modernc.org/sqlite"
)
//go:embed migrations/0001_init.sql
var initSchema string
//go:embed migrations/0002_dynamic_config.sql
var dynamicConfigSchema string
//go:embed migrations/0003_fip_settle_delay.sql
var fipSettleDelaySchema string
//go:embed migrations/0004_inbound_checks_admin.sql
var inboundChecksAdminSchema string
//go:embed migrations/0005_unbounded_sites.sql
var unboundedSitesSchema string
//go:embed migrations/0006_prober_heartbeat.sql
var proberHeartbeatSchema string
//go:embed migrations/0007_ip_registry.sql
var ipRegistrySchema string
//go:embed migrations/0008_auto_cycle.sql
var autoCycleSchema string
//go:embed migrations/0009_scale_indexes.sql
var scaleIndexesSchema string
//go:embed migrations/0010_verdict_integrity.sql
var verdictIntegritySchema string
//go:embed migrations/0011_check_runs.sql
var checkRunsSchema string
// migrations is the ordered list of schema versions. Each entry's SQL is
// applied, in order, for any version greater than the database's current
// PRAGMA user_version — so a fresh database walks the whole list and an
// existing one only picks up what's new.
var migrations = []struct {
version int
sql string
}{
{1, initSchema},
{2, dynamicConfigSchema},
{3, fipSettleDelaySchema},
{4, inboundChecksAdminSchema},
{5, unboundedSitesSchema},
{6, proberHeartbeatSchema},
{7, ipRegistrySchema},
{8, autoCycleSchema},
{9, scaleIndexesSchema},
{10, verdictIntegritySchema},
{11, checkRunsSchema},
}
type DB struct {
*sql.DB
}
// Open opens (creating if necessary) the SQLite database at path, applies
// pragmas suited to a single-writer WAL workload, and runs any pending
// schema migrations.
func Open(ctx context.Context, path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Control API is the sole writer; one connection avoids SQLITE_BUSY
// entirely for writes while still allowing concurrent reads via WAL.
sqlDB.SetMaxOpenConns(1)
for _, pragma := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
} {
if _, err := sqlDB.ExecContext(ctx, pragma); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("apply pragma %q: %w", pragma, err)
}
}
d := &DB{DB: sqlDB}
if err := d.migrate(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
if err := d.adoptOrphanQueueRows(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("adopt queue rows into a run: %w", err)
}
return d, nil
}
// migrate applies every pending migration in order, tracked via
// PRAGMA user_version so repeated startups only apply what's new (and a
// fresh database walks the whole list once).
func (d *DB) migrate(ctx context.Context) error {
var version int
if err := d.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for _, m := range migrations {
if m.version <= version {
continue
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %d: %w", m.version, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version=%d", m.version)); err != nil {
tx.Rollback()
return fmt.Errorf("set user_version=%d: %w", m.version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", m.version, err)
}
}
return nil
}
// Now returns the current time truncated to millisecond precision, the
// granularity used consistently for all timestamp columns.
func Now() time.Time {
return time.Now().UTC().Truncate(time.Millisecond)
}