bugfix and dashboard improvement
Build and Push Docker Image / build (push) Failing after 13s

This commit is contained in:
Worph
2026-09-14 19:04:23 +02:00
parent 0403a6f3bc
commit 515ec04369
8 changed files with 463 additions and 81 deletions
+1 -1
View File
@@ -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>
+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 { 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>