Files
noema/internal/api/system.go
T
Andy Dixon a002a2c100 feat: add authentication, audit log, HTTP API and base web UI
Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque
hashed session tokens with idle/absolute expiry, CSRF token plus Origin
check on cookie sessions, per-IP token bucket and per-account exponential
lockout, three roles enforced by middleware, personal API tokens for
noemactl. Audit log writer used by every admin mutation.

API: request ids, panic recovery, strict CSP and security headers, body
limits, single JSON error envelope, health/ready/status, users, sessions,
tokens, audit, token-protected /metrics.

UI: embedded html/template pages (login, first-run setup, dashboard,
system, users, account, audit, interpretation boundary), light/dark
theme, dependency-free app.js with CSRF-aware fetch and SSE helper.

Binaries: noemad with graceful shutdown, migrations at start, bootstrap
admin from environment, supervised background tasks; noemactl with
status, migrate, user create and meaningful exit codes.
2026-06-15 20:22:19 +01:00

100 lines
3.4 KiB
Go

package api
import (
"context"
"net/http"
"runtime"
"strconv"
"time"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/version"
)
// SystemStatus is the payload of GET /api/v1/system/status.
type SystemStatus struct {
Version string `json:"version"`
UptimeSeconds int64 `json:"uptime_seconds"`
Database DatabaseStatus `json:"database"`
Migrations []MigrationInfo `json:"migrations"`
Goroutines int `json:"goroutines"`
HeapBytes uint64 `json:"heap_bytes"`
SysBytes uint64 `json:"sys_bytes"`
NumCPU int `json:"num_cpu"`
GoVersion string `json:"go_version"`
}
// DatabaseStatus reports connectivity and pool usage.
type DatabaseStatus struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"`
PoolTotal int32 `json:"pool_total"`
PoolIdle int32 `json:"pool_idle"`
PoolMax int32 `json:"pool_max"`
PGVector bool `json:"pgvector"`
}
// MigrationInfo is one migration's state.
type MigrationInfo struct {
Version int `json:"version"`
Name string `json:"name"`
Applied bool `json:"applied"`
AppliedAt string `json:"applied_at,omitempty"`
Drift bool `json:"drift"`
}
// Status collects the system status; shared with the UI system page.
func (s *Server) Status(ctx context.Context) SystemStatus {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
st := SystemStatus{Version: version.Version, UptimeSeconds: int64(time.Since(s.deps.Started).Seconds()),
Goroutines: runtime.NumGoroutine(), HeapBytes: ms.HeapAlloc, SysBytes: ms.Sys, NumCPU: runtime.NumCPU(), GoVersion: runtime.Version()}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := s.deps.DB.QueryRow(ctx, `SELECT version()`).Scan(&st.Database.Version); err != nil {
st.Database.Error = err.Error()
} else {
st.Database.OK = true
s.deps.DB.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='vector')`).Scan(&st.Database.PGVector) //nolint:errcheck
}
stat := s.deps.DB.Stat()
st.Database.PoolTotal, st.Database.PoolIdle, st.Database.PoolMax = stat.TotalConns(), stat.IdleConns(), stat.MaxConns()
if rows, err := storage.Status(ctx, s.deps.DB, s.deps.Migrations); err == nil {
for _, m := range rows {
st.Migrations = append(st.Migrations, MigrationInfo{Version: m.Version, Name: m.Name, Applied: m.Applied, AppliedAt: m.AppliedAt, Drift: m.Drift})
}
}
return st
}
func (s *Server) systemStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.Status(r.Context()))
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok")) //nolint:errcheck
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.deps.DB.Ping(ctx); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ready")) //nolint:errcheck
}
func (s *Server) auditList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
limit, _ := strconv.Atoi(q.Get("limit"))
entries, err := s.deps.Audit.List(r.Context(), before, limit, q.Get("target_kind"), q.Get("target_id"))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
}