Compare commits

..
1 Commits
Author SHA1 Message Date
Worph 515ec04369 bugfix and dashboard improvement
Build and Push Docker Image / build (push) Failing after 13s
2026-09-14 19:04:23 +02:00
8 changed files with 463 additions and 81 deletions
+37 -6
View File
@@ -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. | | **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. | | **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. | | **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. | | **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`. | | **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. | | **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. | | **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 /status # version, uptime, etc.
GET /leader # current leader info (hostname, PID, timestamps) GET /leader # current leader info (hostname, PID, timestamps)
GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty) GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty)
GET /services # registered services + heartbeats GET /neighbors # neighbours heard over UDP (meta-discovery v1)
GET /api/services # alias for /services GET /api/neighbors # same, under /api
GET /services/{name} # one service GET /api/stats # dashboard counters (see below)
GET /services/cleanup/stats
``` ```
`/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 ### Metadata — primary surface
These are the routes other services use. `{hash}` may be a UUID root or These are the routes other services use. `{hash}` may be a UUID root or
+1 -1
View File
@@ -21,7 +21,7 @@
min-height: 100vh; min-height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-BW9EQGCP.js"></script> <script type="module" crossorigin src="/assets/index-Bgo6moAc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css"> <link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
</head> </head>
<body> <body>
+28
View File
@@ -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 '<', "<!doctype "... is not valid JSON` and
// no clue which request produced it — which is exactly how a route deleted
// from the Go router (`/services`, removed in favour of /api/neighbors) sat
// broken on the dashboard.
//
// So: check the content type, and name the URL in every error.
export async function getJSON<T>(url: string, init?: RequestInit): Promise<T> {
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;
}
+171 -71
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getJSON } from '../lib/api';
interface HealthStatus { interface HealthStatus {
status: string; status: string;
@@ -6,6 +7,22 @@ interface HealthStatus {
timestamp: string; 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 { interface WatcherStatus {
id: string; id: string;
path: string; path: string;
@@ -22,13 +39,6 @@ interface WatchersResponse {
count: number; count: number;
} }
interface ServiceInfo {
name: string;
api: string;
capabilities: string[];
timestamp: number;
}
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: '#16213e', background: '#16213e',
borderRadius: '8px', borderRadius: '8px',
@@ -38,53 +48,110 @@ const cardStyle: React.CSSProperties = {
const gridStyle: React.CSSProperties = { const gridStyle: React.CSSProperties = {
display: 'grid', 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', gap: '1rem',
marginBottom: '1rem',
}; };
const statStyle: React.CSSProperties = { const statStyle: React.CSSProperties = {
fontSize: '2rem', fontSize: '2rem',
fontWeight: 'bold', fontWeight: 'bold',
color: '#4ade80', 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 (
<div style={cardStyle}>
<div style={labelStyle}>{label}</div>
<div style={{ ...statStyle, color: pending ? '#6b7a99' : statStyle.color }}>
{value}
</div>
{note && <div style={noteStyle}>{note}</div>}
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [health, setHealth] = useState<HealthStatus | null>(null); const [health, setHealth] = useState<HealthStatus | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null); const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
const fetchData = async () => { const fetchData = async () => {
try { // Settled, not all: one dead endpoint must not blank every tile.
// Fetch health const [healthRes, statsRes, watchersRes] = await Promise.allSettled([
const healthRes = await fetch('/health'); getJSON<HealthStatus>('/health'),
if (healthRes.ok) { getJSON<Stats>('/api/stats'),
setHealth(await healthRes.json()); getJSON<WatchersResponse>('/api/watchers'),
} ]);
if (cancelled) return;
// Fetch watchers status if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
const watchersRes = await fetch('/api/watchers'); if (statsRes.status === 'fulfilled') setStats(statsRes.value);
if (watchersRes.ok) { if (watchersRes.status === 'fulfilled') setWatchersData(watchersRes.value);
setWatchersData(await watchersRes.json());
}
// Fetch services const failures = [healthRes, statsRes, watchersRes]
const servicesRes = await fetch('/services'); .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
if (servicesRes.ok) { .map((r) => String(r.reason?.message ?? r.reason));
const data = await servicesRes.json(); setError(failures.length ? failures.join(' · ') : null);
setServices(data.services || []);
}
setError(null);
} catch (err) {
setError(String(err));
}
}; };
fetchData(); fetchData();
const interval = setInterval(fetchData, 5000); const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval); return () => {
cancelled = true;
clearInterval(interval);
};
}, []); }, []);
const handleTriggerScan = async () => { const handleTriggerScan = async () => {
@@ -95,49 +162,102 @@ export default function Dashboard() {
} }
}; };
// Aggregate watcher stats const isScanning = watchersData?.watchers.some((w) => w.isScanning) || false;
const totalFiles = watchersData?.watchers.reduce((sum, w) => sum + w.fileCount, 0) || 0; const activeWatchers = watchersData?.watchers.filter((w) => w.active).length || 0;
const isScanning = watchersData?.watchers.some(w => w.isScanning) || false; const lastScan =
const activeWatchers = watchersData?.watchers.filter(w => w.active).length || 0; watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 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 ( return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto', overflowY: 'auto', height: '100%' }}>
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1> <h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
{error && ( {error && (
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}> <div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
Error: {error} {error}
</div> </div>
)} )}
<div style={gridStyle}> <div style={gridStyle}>
{/* Health Card */} <Tile
label="Files discovered"
value={formatCount(stats?.files?.count)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Library size"
value={formatBytes(stats?.files?.totalSize)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Metadata entries"
value={formatCount(stats?.records)}
note={
fileless === null
? 'records in the index'
: `${formatCount(fileless)} with no local file`
}
/>
<Tile
label="Redis keys"
value={formatCount(stats?.redisKeys)}
note={stats?.redisMemory ? `${stats.redisMemory} in use` : undefined}
/>
<Tile
label="Identities"
value={formatCount(stats?.identities)}
note={
stats?.udlUsers !== null && stats?.udlUsers !== undefined
? `${formatCount(stats.udlUsers)} with user data`
: 'signing accounts'
}
/>
</div>
<div style={{ ...gridStyle, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3> <div style={labelStyle}>Service health</div>
{health ? ( {health ? (
<> <>
<div style={statStyle}> <div style={statStyle}>
{health.status === 'ok' ? 'Healthy' : health.status} {health.status === 'ok' ? 'Healthy' : health.status}
</div> </div>
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p> <p style={noteStyle}>
Redis: {health.redis ? 'Connected' : 'Disconnected'}
</p>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)} )}
</div> </div>
{/* Watchers Status Card */}
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watchers</h3> <div style={labelStyle}>File watchers</div>
{watchersData ? ( {watchersData ? (
<> <>
<div style={statStyle}>{totalFiles}</div> <div style={statStyle}>
<p>Files discovered</p> {activeWatchers} / {watchersData.count}
<p>Watchers: {activeWatchers} active / {watchersData.count} total</p> </div>
<p>Status: {isScanning ? 'Scanning...' : 'Idle'}</p> <p style={noteStyle}>active watchers</p>
<p style={noteStyle}>Status: {isScanning ? 'Scanning…' : 'Idle'}</p>
{lastScan > 0 && ( {lastScan > 0 && (
<p>Last scan: {new Date(lastScan).toLocaleString()}</p> <p style={noteStyle}>Last scan: {new Date(lastScan).toLocaleString()}</p>
)} )}
<button <button
onClick={handleTriggerScan} onClick={handleTriggerScan}
@@ -155,27 +275,7 @@ export default function Dashboard() {
</button> </button>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)}
</div>
{/* Services Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
<div style={statStyle}>{services.length}</div>
<p>Services registered</p>
{services.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
{services.map((svc) => (
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
<strong>{svc.name}</strong>
<br />
<span style={{ color: '#888', fontSize: '0.9rem' }}>
{svc.capabilities?.join(', ') || 'No capabilities'}
</span>
</li>
))}
</ul>
)} )}
</div> </div>
</div> </div>
+2 -3
View File
@@ -276,9 +276,8 @@ Bootstrap / discovery
GET /status GET /status
GET /leader GET /leader
GET /urls GET /urls
GET /services (alias: /api/services) GET /neighbors (alias: /api/neighbors)
GET /services/{name} GET /api/stats
GET /services/cleanup/stats
Metadata (primary surface for other services) Metadata (primary surface for other services)
GET /meta GET /meta
+8
View File
@@ -50,6 +50,9 @@ type Server struct {
mesh *meshdisco.Node mesh *meshdisco.Node
router *mux.Router router *mux.Router
server *http.Server 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 // SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
@@ -70,6 +73,7 @@ func NewServer(
storage: stor, storage: stor,
search: &searchIndex{}, search: &searchIndex{},
router: mux.NewRouter(), router: mux.NewRouter(),
stats: &statsCache{},
} }
// Initialize file watcher/scanner and dispatcher // 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("/leader", s.handleLeader).Methods("GET")
s.router.HandleFunc("/urls", s.handleURLs).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) // 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/hash-ids", s.handleGetHashIds).Methods("GET")
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET") s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
+164
View File
@@ -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
}
+52
View File
@@ -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
}