171 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|