This commit is contained in:
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<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 { 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 (
|
||||
<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() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null);
|
||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(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<HealthStatus>('/health'),
|
||||
getJSON<Stats>('/api/stats'),
|
||||
getJSON<WatchersResponse>('/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 (
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
|
||||
Error: {error}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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}>
|
||||
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3>
|
||||
<div style={labelStyle}>Service health</div>
|
||||
{health ? (
|
||||
<>
|
||||
<div style={statStyle}>
|
||||
{health.status === 'ok' ? 'Healthy' : health.status}
|
||||
</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>
|
||||
|
||||
{/* Watchers Status Card */}
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watchers</h3>
|
||||
<div style={labelStyle}>File watchers</div>
|
||||
{watchersData ? (
|
||||
<>
|
||||
<div style={statStyle}>{totalFiles}</div>
|
||||
<p>Files discovered</p>
|
||||
<p>Watchers: {activeWatchers} active / {watchersData.count} total</p>
|
||||
<p>Status: {isScanning ? 'Scanning...' : 'Idle'}</p>
|
||||
<div style={statStyle}>
|
||||
{activeWatchers} / {watchersData.count}
|
||||
</div>
|
||||
<p style={noteStyle}>active watchers</p>
|
||||
<p style={noteStyle}>Status: {isScanning ? 'Scanning…' : 'Idle'}</p>
|
||||
{lastScan > 0 && (
|
||||
<p>Last scan: {new Date(lastScan).toLocaleString()}</p>
|
||||
<p style={noteStyle}>Last scan: {new Date(lastScan).toLocaleString()}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTriggerScan}
|
||||
@@ -155,27 +275,7 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p>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>
|
||||
<p style={noteStyle}>Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user