Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
515ec04369 |
@@ -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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user