diff --git a/README.md b/README.md index a495da2..2090966 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ HTTP API. |---------|-------------| | **Leader gate** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner. The Go binary therefore only ever runs as leader. | | **Redis owner** | Supervisord starts Redis with AOF + RDB persistence inside the container; meta-core connects to it on localhost. | -| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, services, and file access by CID. | +| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, neighbours, and file access by CID. | | **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the underlying Redis Streams so external services never speak Redis. | | **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. | -| **Service discovery** | File-based registry under `/meta-core/services/*.json` with heartbeat + dead-service cleanup. | +| **Service discovery** | meta-discovery v1: UDP multicast announce/listen on `239.255.77.1:9399`, neighbours served at `/neighbors`. The file-based registry under `/meta-core/services/*.json` it replaced is gone. | | **File watcher / scanner** | Recursive scan + MidHash256 computation; emits events on `file:events`. | | **Mount management** | rclone-only mount manager (SMB rendered into `:smb:`, plus pre-defined remotes). Read-only by construction. | | **UUID-rooted storage** | Roots are UUIDv7 (Crockford Base32, ULID layout); CIDs are reverse-index aliases. Schema-version sentinel refuses to boot against stale data. | @@ -167,12 +167,43 @@ GET /health # storage + role GET /status # version, uptime, etc. GET /leader # current leader info (hostname, PID, timestamps) GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty) -GET /services # registered services + heartbeats -GET /api/services # alias for /services -GET /services/{name} # one service -GET /services/cleanup/stats +GET /neighbors # neighbours heard over UDP (meta-discovery v1) +GET /api/neighbors # same, under /api +GET /api/stats # dashboard counters (see below) ``` +`/services`, `/api/services`, `/services/{name}` and `/services/cleanup/stats` +are **gone** — the registration-and-heartbeat registry they served was replaced +by meta-discovery v1 (`/neighbors`). nginx answers an unknown non-`/api` path +with the dashboard's `index.html`, so a stale caller gets `200 text/html` rather +than a 404; that is what made the dashboard fail with +`SyntaxError: Unexpected token '<'` until 1.0.21. + +#### `GET /api/stats` + +Every headline counter in one request, split by cost: + +```jsonc +{ + "records": 88709, // SCARD file:__index__ — metadata roots, file or not + "redisKeys": 3115447, // DBSIZE — the flat per-field keys dominate + "redisMemory": "588.74M", + "identities": 3, // signing accounts on disk + "files": { "count": 4864, "totalSize": 64587914424 }, // null until first sweep + "udlUsers": 5, // accounts holding User Data Layer records + "sweptAt": 1789402401970, + "sweeping": false +} +``` + +The first four are O(1) commands and are live on every call. `files` and +`udlUsers` need a keyspace walk (~0.5s and ~3s respectively on an 88k-record +box), so they are served from a single-flight cache refreshed in the background +with a 60s TTL — the walk is **never** on the request path, because the +dashboard polls and ten open tabs must not cost ten Redis walks. Prefer this +over `/api/kv/info`, which does one `GET` per root (6s+) and sums `sizeByte` +across records that share a file. + ### Metadata — primary surface These are the routes other services use. `{hash}` may be a UUID root or diff --git a/dashboard/dist/index.html b/dashboard/dist/index.html index 9734e86..3c0a69b 100644 --- a/dashboard/dist/index.html +++ b/dashboard/dist/index.html @@ -21,7 +21,7 @@ min-height: 100vh; } - + diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts new file mode 100644 index 0000000..07bc9e5 --- /dev/null +++ b/dashboard/src/lib/api.ts @@ -0,0 +1,28 @@ +// JSON fetch that fails with a sentence instead of a parser error. +// +// nginx serves the dashboard with `try_files $uri $uri/ /index.html`, so ANY +// path it does not have a proxy rule for answers 200 text/html with the SPA +// shell. A caller that does `res.ok && res.json()` on such a path gets +// `SyntaxError: Unexpected token '<', "(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + const ct = res.headers.get('content-type') || ''; + + if (!res.ok) { + throw new Error(`${url} → HTTP ${res.status}`); + } + if (!ct.includes('application/json')) { + // The SPA fallback is the overwhelmingly likely cause; say so. + throw new Error( + `${url} → ${res.status} ${ct || 'no content-type'} (not JSON — the route is ` + + `missing and nginx served the dashboard shell instead)` + ); + } + return (await res.json()) as T; +} diff --git a/dashboard/src/pages/Dashboard.tsx b/dashboard/src/pages/Dashboard.tsx index 7b68e22..ac5d29d 100644 --- a/dashboard/src/pages/Dashboard.tsx +++ b/dashboard/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { getJSON } from '../lib/api'; interface HealthStatus { status: string; @@ -6,6 +7,22 @@ interface HealthStatus { timestamp: string; } +interface FileStats { + count: number; + totalSize: number; +} + +interface Stats { + records: number; + redisKeys: number; + redisMemory: string; + identities: number; + files: FileStats | null; + udlUsers: number | null; + sweptAt: number; + sweeping: boolean; +} + interface WatcherStatus { id: string; path: string; @@ -22,13 +39,6 @@ interface WatchersResponse { count: number; } -interface ServiceInfo { - name: string; - api: string; - capabilities: string[]; - timestamp: number; -} - const cardStyle: React.CSSProperties = { background: '#16213e', borderRadius: '8px', @@ -38,53 +48,110 @@ const cardStyle: React.CSSProperties = { const gridStyle: React.CSSProperties = { display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', + // 200px, not 220: five tiles must fit one row inside the 1200px container + // (1136 content - 4x16 gap = 1072 / 5 = 214 each) before wrapping. + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', + marginBottom: '1rem', }; const statStyle: React.CSSProperties = { fontSize: '2rem', fontWeight: 'bold', color: '#4ade80', + lineHeight: 1.2, }; +const labelStyle: React.CSSProperties = { + marginBottom: '0.75rem', + color: '#888', + fontSize: '0.85rem', + textTransform: 'uppercase', + letterSpacing: '0.04em', +}; + +const noteStyle: React.CSSProperties = { + color: '#6b7a99', + fontSize: '0.8rem', + marginTop: '0.4rem', +}; + +const numberFmt = new Intl.NumberFormat(); + +function formatCount(n: number | null | undefined): string { + return n === null || n === undefined ? '—' : numberFmt.format(n); +} + +// Binary units: this counts bytes on disk, and every other tool the operator +// compares against (du, docker, the mount page) reports GiB. +function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) return '—'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value >= 100 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function Tile({ + label, + value, + note, + pending, +}: { + label: string; + value: string; + note?: string; + pending?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+ {note &&
{note}
} +
+ ); +} + export default function Dashboard() { const [health, setHealth] = useState(null); + const [stats, setStats] = useState(null); const [watchersData, setWatchersData] = useState(null); - const [services, setServices] = useState([]); const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; + const fetchData = async () => { - try { - // Fetch health - const healthRes = await fetch('/health'); - if (healthRes.ok) { - setHealth(await healthRes.json()); - } + // Settled, not all: one dead endpoint must not blank every tile. + const [healthRes, statsRes, watchersRes] = await Promise.allSettled([ + getJSON('/health'), + getJSON('/api/stats'), + getJSON('/api/watchers'), + ]); + if (cancelled) return; - // Fetch watchers status - const watchersRes = await fetch('/api/watchers'); - if (watchersRes.ok) { - setWatchersData(await watchersRes.json()); - } + if (healthRes.status === 'fulfilled') setHealth(healthRes.value); + if (statsRes.status === 'fulfilled') setStats(statsRes.value); + if (watchersRes.status === 'fulfilled') setWatchersData(watchersRes.value); - // Fetch services - const servicesRes = await fetch('/services'); - if (servicesRes.ok) { - const data = await servicesRes.json(); - setServices(data.services || []); - } - - setError(null); - } catch (err) { - setError(String(err)); - } + const failures = [healthRes, statsRes, watchersRes] + .filter((r): r is PromiseRejectedResult => r.status === 'rejected') + .map((r) => String(r.reason?.message ?? r.reason)); + setError(failures.length ? failures.join(' · ') : null); }; fetchData(); const interval = setInterval(fetchData, 5000); - return () => clearInterval(interval); + return () => { + cancelled = true; + clearInterval(interval); + }; }, []); const handleTriggerScan = async () => { @@ -95,49 +162,102 @@ export default function Dashboard() { } }; - // Aggregate watcher stats - const totalFiles = watchersData?.watchers.reduce((sum, w) => sum + w.fileCount, 0) || 0; - const isScanning = watchersData?.watchers.some(w => w.isScanning) || false; - const activeWatchers = watchersData?.watchers.filter(w => w.active).length || 0; - const lastScan = watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0; + const isScanning = watchersData?.watchers.some((w) => w.isScanning) || false; + const activeWatchers = watchersData?.watchers.filter((w) => w.active).length || 0; + const lastScan = + watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0; + + // Records we hold metadata for but have no local file: gateway/cid-rooted + // entries, plus anything whose file went away. The interesting number on a + // client box, where it should be most of them. + const fileless = + stats && stats.files ? Math.max(0, stats.records - stats.files.count) : null; + + const sweepPending = !!stats && !stats.files; + const sweptNote = (() => { + if (!stats) return undefined; + if (sweepPending) return stats.sweeping ? 'scanning…' : 'not scanned yet'; + if (stats.sweeping) return 'refreshing…'; + if (stats.sweptAt) return `as of ${new Date(stats.sweptAt).toLocaleTimeString()}`; + return undefined; + })(); return ( -
+

meta-core Dashboard

{error && (
- Error: {error} + {error}
)}
- {/* Health Card */} + + + + + +
+ +
-

Service Health

+
Service health
{health ? ( <>
{health.status === 'ok' ? 'Healthy' : health.status}
-

Redis: {health.redis ? 'Connected' : 'Disconnected'}

+

+ Redis: {health.redis ? 'Connected' : 'Disconnected'} +

) : ( -

Loading...

+

Loading…

)}
- {/* Watchers Status Card */}
-

File Watchers

+
File watchers
{watchersData ? ( <> -
{totalFiles}
-

Files discovered

-

Watchers: {activeWatchers} active / {watchersData.count} total

-

Status: {isScanning ? 'Scanning...' : 'Idle'}

+
+ {activeWatchers} / {watchersData.count} +
+

active watchers

+

Status: {isScanning ? 'Scanning…' : 'Idle'}

{lastScan > 0 && ( -

Last scan: {new Date(lastScan).toLocaleString()}

+

Last scan: {new Date(lastScan).toLocaleString()}

)}
- - {/* Services Card */} -
-

Connected Services

-
{services.length}
-

Services registered

- {services.length > 0 && ( -
    - {services.map((svc) => ( -
  • - {svc.name} -
    - - {svc.capabilities?.join(', ') || 'No capabilities'} - -
  • - ))} -
+

Loading…

)}
diff --git a/docs/meta-core-architecture.md b/docs/meta-core-architecture.md index b57dbac..b97f521 100644 --- a/docs/meta-core-architecture.md +++ b/docs/meta-core-architecture.md @@ -276,9 +276,8 @@ Bootstrap / discovery GET /status GET /leader GET /urls - GET /services (alias: /api/services) - GET /services/{name} - GET /services/cleanup/stats + GET /neighbors (alias: /api/neighbors) + GET /api/stats Metadata (primary surface for other services) GET /meta diff --git a/internal/api/server.go b/internal/api/server.go index 7d032a5..6b6a354 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -50,6 +50,9 @@ type Server struct { mesh *meshdisco.Node router *mux.Router server *http.Server + // stats caches the keyspace-walk half of GET /api/stats so a polling + // dashboard never puts a multi-second Redis walk on the request path. + stats *statsCache } // SetMeshNode attaches the UDP discovery node. Separate from NewServer so the @@ -70,6 +73,7 @@ func NewServer( storage: stor, search: &searchIndex{}, router: mux.NewRouter(), + stats: &statsCache{}, } // Initialize file watcher/scanner and dispatcher @@ -169,6 +173,10 @@ func (s *Server) setupRoutes() { s.router.HandleFunc("/leader", s.handleLeader).Methods("GET") s.router.HandleFunc("/urls", s.handleURLs).Methods("GET") + // Dashboard headline counters in one request. Cheap half live, keyspace + // walks served from a background-refreshed cache — see stats.go. + s.router.HandleFunc("/api/stats", s.handleStats).Methods("GET") + // Metadata Editor API routes (must be before /meta/{hash} routes) s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET") s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET") diff --git a/internal/api/stats.go b/internal/api/stats.go new file mode 100644 index 0000000..03fcc5b --- /dev/null +++ b/internal/api/stats.go @@ -0,0 +1,164 @@ +package api + +import ( + "log" + "net/http" + "sync" + "time" + + "github.com/metazla/meta-core/internal/identity" +) + +// statsSweepTTL is how long a keyspace-walk result stays good. The walk is +// never on the request path (see statsCache), so this only controls how stale +// the library tiles may look, not how slow the endpoint is. +const statsSweepTTL = 60 * time.Second + +// fileStatsJSON is the file-backed half of the library: the roots that carry +// filePath+sizeByte+mtimeNano. Gateway / cid-rooted records have no file on +// disk and are counted in Records, not here — the gap between the two numbers +// is exactly "metadata we hold for files we do not have". +type fileStatsJSON struct { + Count int `json:"count"` + TotalSize int64 `json:"totalSize"` +} + +// StatsResponse is the body of GET /api/stats — every headline number the +// dashboard shows, in one request. +// +// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are +// O(1) commands (SCARD, DBSIZE, INFO) plus a directory listing, recomputed on +// every call. Files and UDLUsers need a keyspace walk — ~0.5s for the file +// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served +// from a background-refreshed cache and are null until the first sweep lands. +type StatsResponse struct { + Records int64 `json:"records"` + RedisKeys int64 `json:"redisKeys"` + RedisMemory string `json:"redisMemory"` + Identities int `json:"identities"` + Files *fileStatsJSON `json:"files"` + UDLUsers *int `json:"udlUsers"` + // SweptAt is when the cached half was computed (unix ms), 0 if never. + SweptAt int64 `json:"sweptAt"` + // Sweeping reports that a refresh is in flight, so the UI can show the + // cached numbers as provisional instead of pretending they are live. + Sweeping bool `json:"sweeping"` +} + +// statsCache holds the expensive half of /api/stats. +// +// Why a cache and not just "compute it": the dashboard polls. A 3s Redis walk +// per tick, per open tab, is a self-inflicted outage on a box that is also +// serving playback — the same shape as meta-sort's /api/stats full sweep. So +// the handler NEVER waits on a walk: it returns whatever the last sweep +// produced and kicks off a new one if the old one is stale. `running` makes +// that single-flight, so ten tabs cost one walk. +type statsCache struct { + mu sync.Mutex + files *fileStatsJSON + udlUsers *int + sweptAt time.Time + running bool +} + +// snapshot returns the cached values and whether a refresh is in flight. +func (c *statsCache) snapshot() (*fileStatsJSON, *int, time.Time, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.files, c.udlUsers, c.sweptAt, c.running +} + +// refreshIfStale starts a background sweep unless one is already running or +// the last result is still fresh. Returns true if it started one. +func (c *statsCache) refreshIfStale(sweep func() (*fileStatsJSON, *int)) bool { + c.mu.Lock() + if c.running || time.Since(c.sweptAt) < statsSweepTTL { + c.mu.Unlock() + return false + } + c.running = true + c.mu.Unlock() + + go func() { + files, udl := sweep() + c.mu.Lock() + defer c.mu.Unlock() + c.running = false + // A failed sweep leaves the previous numbers in place rather than + // blanking the tiles; sweptAt still advances so a hard-failing walk + // (Redis down) does not spin once per poll. + c.sweptAt = time.Now() + if files != nil { + c.files = files + } + if udl != nil { + c.udlUsers = udl + } + }() + return true +} + +// handleStats handles GET /api/stats. +func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { + if !s.storage.IsConnected() { + writeError(w, http.StatusServiceUnavailable, "storage not connected") + return + } + + resp := StatsResponse{} + + if n, err := s.storage.CountRoots(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } else { + resp.Records = n + } + if n, err := s.storage.CountKeys(); err == nil { + resp.RedisKeys = n + } + if mem, err := s.storage.GetMemoryInfo(); err == nil { + resp.RedisMemory = mem + } else { + resp.RedisMemory = "N/A" + } + if list, err := identity.List(s.config.IdentityAccountsDir()); err == nil { + resp.Identities = len(list) + } + + s.stats.refreshIfStale(s.sweepStats) + files, udl, sweptAt, sweeping := s.stats.snapshot() + resp.Files = files + resp.UDLUsers = udl + resp.Sweeping = sweeping + if !sweptAt.IsZero() { + resp.SweptAt = sweptAt.UnixMilli() + } + + writeJSON(w, http.StatusOK, resp) +} + +// sweepStats runs the two keyspace walks behind the cached tiles. Called off +// the request path only. A failing half returns nil so the other half still +// lands. +func (s *Server) sweepStats() (*fileStatsJSON, *int) { + var files *fileStatsJSON + if tuples, err := s.storage.GetTuplesForAllFiles(); err != nil { + log.Printf("[API] stats: file tuple sweep failed: %v", err) + } else { + fs := fileStatsJSON{Count: len(tuples)} + for _, t := range tuples { + fs.TotalSize += t.Size + } + files = &fs + } + + var udl *int + if stats, err := s.storage.UDLAllUserStats(); err != nil { + log.Printf("[API] stats: UDL user sweep failed: %v", err) + } else { + n := len(stats) + udl = &n + } + + return files, udl +} diff --git a/internal/storage/stats.go b/internal/storage/stats.go new file mode 100644 index 0000000..915baaf --- /dev/null +++ b/internal/storage/stats.go @@ -0,0 +1,52 @@ +package storage + +import ( + "context" + "fmt" + "time" +) + +// CountRoots returns the number of metadata roots in the file index. +// +// SCARD, not SMEMBERS: GetAllHashIDs ships every member over the wire (~3MB +// and rising on an 88k-record box) just so the caller can take len(). A +// dashboard tile that polls needs the number, never the list. +func (c *Client) CountRoots() (int64, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.client == nil { + return 0, fmt.Errorf("not connected") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + n, err := c.client.SCard(ctx, c.buildIndexKey()).Result() + if err != nil { + return 0, fmt.Errorf("scard failed: %w", err) + } + return n, nil +} + +// CountKeys returns DBSIZE — every key in the Redis database, not just the +// ones meta-core owns. It is the denominator for "how much of Redis is +// records": ~3.1M keys against ~89k roots means the flat per-field keys +// (file:{root}/{field}) dominate, which is expected. +func (c *Client) CountKeys() (int64, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.client == nil { + return 0, fmt.Errorf("not connected") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + n, err := c.client.DBSize(ctx).Result() + if err != nil { + return 0, fmt.Errorf("dbsize failed: %w", err) + } + return n, nil +}