package main import ( "database/sql" "embed" "fmt" "time" "fn-registry/functions/infra" ) //go:embed migrations/*.sql var migrationsFS embed.FS // openOpsDB opens the operations.db and applies pending migrations. func openOpsDB(path string) (*sql.DB, error) { db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("open ops db: %w", err) } if err := infra.ApplyVersionedMigrations(db, migrationsFS, "migrations"); err != nil { db.Close() return nil, fmt.Errorf("apply migrations: %w", err) } return db, nil } // upsertState writes the latest check into service_state. When `overall` changed // vs previous row, also appends a row to service_transition for audit. func upsertState(db *sql.DB, c ServiceCheck) error { now := time.Now().Unix() var prevOverall string row := db.QueryRow( "SELECT overall FROM service_state WHERE app_id = ? AND pc_id = ?", c.AppID, c.PCID, ) if err := row.Scan(&prevOverall); err != nil && err != sql.ErrNoRows { return err } changeTS := now if prevOverall == c.Overall { // Preserve last_change_ts. var oldChange int64 _ = db.QueryRow( "SELECT last_change_ts FROM service_state WHERE app_id = ? AND pc_id = ?", c.AppID, c.PCID, ).Scan(&oldChange) if oldChange > 0 { changeTS = oldChange } } listening := 0 if c.PortListening { listening = 1 } _, err := db.Exec(` INSERT INTO service_state (app_id, pc_id, systemd_state, port_listening, http_status, http_latency_ms, last_check_ts, last_change_ts, last_error, overall) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id, pc_id) DO UPDATE SET systemd_state = excluded.systemd_state, port_listening = excluded.port_listening, http_status = excluded.http_status, http_latency_ms = excluded.http_latency_ms, last_check_ts = excluded.last_check_ts, last_change_ts = excluded.last_change_ts, last_error = excluded.last_error, overall = excluded.overall `, c.AppID, c.PCID, c.SystemdState, listening, c.HTTPStatus, c.HTTPLatencyMs, now, changeTS, c.Error, c.Overall) if err != nil { return fmt.Errorf("upsert state: %w", err) } if prevOverall != "" && prevOverall != c.Overall { _, err = db.Exec(` INSERT INTO service_transition (ts, app_id, pc_id, from_state, to_state, detail) VALUES (?, ?, ?, ?, ?, ?) `, now, c.AppID, c.PCID, prevOverall, c.Overall, c.Error) } return err } // StateRow is one row of /api/services output. type StateRow struct { AppID string `json:"app_id"` AppName string `json:"app_name"` PCID string `json:"pc_id"` IsSelf bool `json:"is_self"` Reachable bool `json:"reachable"` Runtime string `json:"runtime"` Port int `json:"port"` HealthEndpoint string `json:"health_endpoint"` SystemdUnit string `json:"systemd_unit"` SystemdScope string `json:"systemd_scope"` SystemdState string `json:"systemd_state"` PortListening bool `json:"port_listening"` HTTPStatus int `json:"http_status"` HTTPLatencyMs int `json:"http_latency_ms"` LastCheckTS int64 `json:"last_check_ts"` LastChangeTS int64 `json:"last_change_ts"` LastError string `json:"last_error"` Overall string `json:"overall"` } // loadStates returns the latest snapshot from service_state joined with the // declared service metadata from registry.db (already merged in memory by the // caller through the targetsCache). func loadStates(opsDB *sql.DB, targets []Target, selfPC string) ([]StateRow, error) { rows := make([]StateRow, 0, len(targets)) for _, t := range targets { r := StateRow{ AppID: t.AppID, AppName: t.Name, PCID: t.PCID, IsSelf: t.PCID == selfPC, Runtime: t.Runtime, Port: t.Port, HealthEndpoint: t.HealthEndpoint, SystemdUnit: t.SystemdUnit, SystemdScope: t.SystemdScope, Overall: "unknown", } var listening int err := opsDB.QueryRow(` SELECT systemd_state, port_listening, http_status, http_latency_ms, last_check_ts, last_change_ts, last_error, overall FROM service_state WHERE app_id = ? AND pc_id = ? `, t.AppID, t.PCID).Scan( &r.SystemdState, &listening, &r.HTTPStatus, &r.HTTPLatencyMs, &r.LastCheckTS, &r.LastChangeTS, &r.LastError, &r.Overall, ) if err != nil && err != sql.ErrNoRows { return nil, err } r.PortListening = listening != 0 rows = append(rows, r) } return rows, nil }