SkateSpots/src/pages/OpsPage.tsx

171 lines
7.5 KiB
TypeScript

import { useCallback, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Activity, AlertTriangle, CheckCircle2, Loader2, RefreshCw, Server, ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { useTranslation } from '@/contexts/language';
const OPS_URL = 'https://relay.skate-spots.org/ops';
interface OpsStatus {
updatedAt: string;
relay: { local: string; public: string };
version: string;
cordn: { announces20m: number; rejects15m: number };
storage: { diskPct: number; memAvailMiB: number; mediaBytes: number };
tls: { days: number };
services: Record<string, boolean>;
issues: string[];
}
function Metric({ label, value, good = true }: { label: string; value: string; good?: boolean }) {
return (
<div className="rounded-xl border border-white/10 bg-black/40 p-3">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</p>
<p className={`mt-1 text-lg font-black ${good ? 'text-white' : 'text-red-400'}`}>{value}</p>
</div>
);
}
/**
* Admin-only, read-only relay operator snapshot. The endpoint requires a
* NIP-98 signature from one of the two relay admin pubkeys; this page never
* exposes a restart/config action.
*
* Labels are intentionally English-only: this is an internal operator tool,
* not a public product surface.
*/
export default function OpsPage() {
const { user } = useCurrentUser();
const { t } = useTranslation();
const [status, setStatus] = useState<OpsStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useSeoMeta({
title: 'Operator status — SkateSpots',
description: 'Private relay health snapshot.',
});
const load = useCallback(async () => {
if (!user) return;
setLoading(true);
setError(null);
try {
const event = await user.signer.signEvent({
kind: 27235,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', OPS_URL],
['method', 'GET'],
['expiration', String(Math.floor(Date.now() / 1000) + 120)],
],
});
const response = await fetch(OPS_URL, {
headers: { authorization: `Nostr ${btoa(JSON.stringify(event))}` },
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
setStatus(body as OpsStatus);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [user]);
if (!isAdminPubkey(user?.pubkey)) {
return (
<div className="container max-w-lg px-4 py-16 text-center">
<ShieldAlert className="mx-auto mb-3 h-10 w-10 text-red-400/60" />
<h1 className="text-xl font-black uppercase text-white">Admins only</h1>
<p className="mt-2 text-sm text-gray-500">This page is restricted to the two relay admin npubs.</p>
</div>
);
}
return (
<div className="container max-w-3xl space-y-4 px-4 py-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[#ccff00]">
<Activity className="h-4 w-4" />
<span className="text-[10px] font-black uppercase tracking-widest">Private operator surface</span>
</div>
<h1 className="mt-1 text-3xl font-black uppercase leading-none text-white">Relay status</h1>
<p className="mt-1 text-xs text-gray-500">Read-only snapshot, signed by your admin npub.</p>
</div>
<Button size="sm" variant="outline" onClick={() => void load()} disabled={loading} className="gap-2">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Load status
</Button>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" /> {error}
</div>
)}
{!status ? (
<Card className="border-white/10 bg-black/50">
<CardContent className="space-y-2 py-10 text-center">
<Server className="mx-auto h-10 w-10 text-gray-600" />
<p className="text-sm text-gray-400">Load the signed snapshot to inspect the relay.</p>
<p className="text-[11px] text-gray-600">With Amber this requires one approval.</p>
</CardContent>
</Card>
) : (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-xs text-gray-500">
{status.issues.length === 0 ? (
<><CheckCircle2 className="h-3.5 w-3.5 text-green-400" /> All checks passing</>
) : (
<><AlertTriangle className="h-3.5 w-3.5 text-amber-400" /> {status.issues.length} issue(s)</>
)}
</span>
<span className="text-[10px] text-gray-600">Updated {new Date(status.updatedAt).toLocaleString()}</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Metric label="Relay" value={`${status.relay.public} / ${status.relay.local}`} good={status.relay.public === '200' && status.relay.local === '200'} />
<Metric label="Version" value={status.version || '—'} />
<Metric label="Disk" value={`${status.storage.diskPct}%`} good={status.storage.diskPct < 85} />
<Metric label="Memory free" value={`${status.storage.memAvailMiB} MiB`} good={status.storage.memAvailMiB > 300} />
<Metric label="TLS" value={`${status.tls.days}d`} good={status.tls.days > 14} />
<Metric label="Media" value={`${(status.storage.mediaBytes / 1024 / 1024).toFixed(1)} MiB`} />
<Metric label="Cordn announces" value={String(status.cordn.announces20m)} good={status.cordn.announces20m > 0} />
<Metric label="Cordn 429s / 15m" value={String(status.cordn.rejects15m)} good={status.cordn.rejects15m < 40} />
</div>
<Card className="border-white/10 bg-black/50">
<CardHeader className="pb-2"><CardTitle className="text-sm text-white">Services</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3">
{Object.entries(status.services).map(([name, up]) => (
<div key={name} className="flex items-center gap-2 text-gray-400">
<span className={`h-1.5 w-1.5 rounded-full ${up ? 'bg-green-400' : 'bg-red-400'}`} />
{name}
</div>
))}
</CardContent>
</Card>
{status.issues.length > 0 && (
<Card className="border-amber-500/25 bg-amber-500/[0.04]">
<CardHeader className="pb-2"><CardTitle className="text-sm text-amber-300">Issues</CardTitle></CardHeader>
<CardContent className="space-y-1 text-xs text-amber-200/80">
{status.issues.map((issue) => <p key={issue}>• {issue}</p>)}
</CardContent>
</Card>
)}
</>
)}
<p className="text-center text-[10px] text-gray-700">{t('settingsUi.settingsPage.advanced')} · read-only</p>
</div>
);
}