Add owner-only ContextVM server health check (Settings > Advanced)

Probes the ContextVM relay for the CVM server's own events and reports
online/stale/offline with a last-seen timestamp. Gated to the two
platform owner pubkeys (renders null for everyone else). Notes that
Sasquatch's data layer queries relays directly and is unaffected by the
server being down.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-29 22:27:22 +07:00
parent 148ae251ef
commit e6517b5c70
3 changed files with 302 additions and 0 deletions

View file

@ -0,0 +1,191 @@
/**
* CVMHealthCheck — owner-only status probe for the SkateSpots ContextVM server.
*
* Renders NOTHING unless the logged-in user is one of the two platform owners
* (PLATFORM_OWNER_PUBKEYS). Pings the ContextVM relay for the server's own
* events and reports online / stale / offline with a last-seen timestamp.
*/
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import {
useCVMServerHealth,
CVM_SERVER_PUBKEY,
CVM_RELAY,
type CVMHealthStatus,
} from '@/hooks/useCVMServerHealth';
import { cn } from '@/lib/utils';
import {
Activity,
CheckCircle2,
XCircle,
AlertTriangle,
Loader2,
ExternalLink,
} from 'lucide-react';
function timeAgo(unixSeconds: number): string {
const s = Math.floor(Date.now() / 1000 - unixSeconds);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
const STATUS_META: Record<
CVMHealthStatus,
{ label: string; color: string; ring: string; dot: string }
> = {
idle: { label: 'Not checked', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
checking: { label: 'Checking…', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
online: { label: 'Online', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
stale: { label: 'Stale', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
offline: { label: 'Offline', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
error: { label: 'Unreachable', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
};
export function CVMHealthCheck() {
const { user } = useCurrentUser();
const health = useCVMServerHealth();
// Owner-only gate. Never render for anyone else.
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
if (!isOwner) return null;
const meta = STATUS_META[health.status];
const StatusIcon =
health.status === 'checking'
? Loader2
: health.status === 'online'
? CheckCircle2
: health.status === 'stale'
? AlertTriangle
: health.status === 'idle'
? Activity
: XCircle;
return (
<div className={cn('rounded-2xl border overflow-hidden bg-white/2', meta.ring)}>
<div className="px-4 py-3.5 border-b border-white/5 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white flex items-center gap-2">
<Activity className="h-4 w-4 text-red-400" />
ContextVM Server
<span className="text-[9px] font-black uppercase tracking-widest text-red-400/70 bg-red-500/10 px-1.5 py-0.5 rounded">
Owner
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Health probe for the Sasquatch CVM data server
</p>
</div>
<span
className={cn(
'shrink-0 flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full border',
meta.ring,
meta.color,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
</span>
</div>
<div className="px-4 py-3.5 space-y-3">
{/* Status detail line */}
<div className="flex items-start gap-2.5">
<StatusIcon
className={cn(
'h-4 w-4 mt-0.5 shrink-0',
meta.color,
health.status === 'checking' && 'animate-spin',
)}
/>
<div className="text-xs leading-relaxed">
{health.status === 'idle' && (
<span className="text-muted-foreground">
Tap “Check server” to ping the ContextVM relay for the server’s heartbeat.
</span>
)}
{health.status === 'checking' && (
<span className="text-blue-300">Querying {CVM_RELAY}…</span>
)}
{health.status === 'online' && health.lastSeen && (
<span className="text-green-400">
Server is announcing. Last event {timeAgo(health.lastSeen)}.
</span>
)}
{health.status === 'stale' && health.lastSeen && (
<span className="text-amber-400">
Server found, but last activity was {timeAgo(health.lastSeen)} — it may be
down or idle.
</span>
)}
{health.status === 'offline' && (
<span className="text-red-400">
No events from the server on the relay. It’s likely not running.
</span>
)}
{health.status === 'error' && (
<span className="text-red-400">{health.error}</span>
)}
</div>
</div>
{/* Meta grid */}
{(health.eventCount > 0 || health.checkedAt) && (
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-lg bg-black/30 border border-white/5 px-2.5 py-2">
<p className="text-gray-500 uppercase tracking-wide font-bold text-[9px]">
Events found
</p>
<p className="text-white font-mono">{health.eventCount}</p>
</div>
<div className="rounded-lg bg-black/30 border border-white/5 px-2.5 py-2">
<p className="text-gray-500 uppercase tracking-wide font-bold text-[9px]">
Last seen
</p>
<p className="text-white font-mono">
{health.lastSeen ? timeAgo(health.lastSeen) : '—'}
</p>
</div>
</div>
)}
{/* Note — Sasquatch does NOT depend on this server. */}
<p className="text-[11px] text-muted-foreground/70 leading-relaxed">
Note: Sasquatch queries data relays directly — it keeps working even if this
server is down. This check is for operations only.
</p>
{/* Actions */}
<div className="flex items-center gap-2 pt-0.5">
<button
onClick={health.check}
disabled={health.status === 'checking'}
className="flex items-center gap-2 text-sm font-bold text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{health.status === 'checking' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
Check server
</button>
<a
href={`https://contextvm.org/s/${CVM_SERVER_PUBKEY}`}
target="_blank"
rel="noopener noreferrer"
className="ml-auto flex items-center gap-1.5 text-xs font-semibold text-gray-400 hover:text-white transition-colors"
>
Directory <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
/**
* useCVMServerHealth
* ─────────────────────────────────────────────────────────────────────────────
* Owner-only health probe for the SkateSpots ContextVM (CVM) MCP server.
*
* A ContextVM server lives as a Nostr identity that publishes a server
* announcement and listens for encrypted MCP requests on the ContextVM relay.
* The most reliable "is it alive?" signal we can read from the browser is the
* server's own events on that relay: the announcement (kind 31990) plus any
* newer heartbeat/activity. We report status + how long ago the server was
* last seen.
*
* NOTE: the user-facing Sasquatch data layer (`useCVMClient`) queries the data
* relays DIRECTLY and does NOT depend on this server being up. This probe is
* purely an operational status check for platform owners.
*/
import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
/** The SkateSpots CVM server identity (see contextvm.org/s/<pubkey>). */
export const CVM_SERVER_PUBKEY =
'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530';
/** The relay the CVM server announces on. */
export const CVM_RELAY = 'wss://relay.contextvm.org';
/** Freshness threshold: if the newest event is older than this, call it stale. */
const STALE_AFTER_MS = 60 * 60 * 1000; // 1 hour
export type CVMHealthStatus = 'idle' | 'checking' | 'online' | 'stale' | 'offline' | 'error';
export interface CVMHealthResult {
status: CVMHealthStatus;
/** Unix seconds of the newest event authored by the server, if any. */
lastSeen: number | null;
/** Number of server-authored events found on the probe. */
eventCount: number;
/** Human-readable error, when status === 'error'. */
error: string | null;
/** When the probe last ran (ms epoch). */
checkedAt: number | null;
}
const INITIAL: CVMHealthResult = {
status: 'idle',
lastSeen: null,
eventCount: 0,
error: null,
checkedAt: null,
};
export function useCVMServerHealth() {
const { nostr } = useNostr();
const [result, setResult] = useState<CVMHealthResult>(INITIAL);
const check = useCallback(async () => {
setResult((r) => ({ ...r, status: 'checking', error: null }));
try {
// Probe the ContextVM relay directly for anything the server has authored.
// We ask for a small window across the kinds a CVM server emits:
// 31990 (handler/announcement), plus recent activity of any kind.
const relay = nostr.relay(CVM_RELAY);
const signal = AbortSignal.timeout(8000);
const events = await relay.query(
[{ authors: [CVM_SERVER_PUBKEY], limit: 25 }],
{ signal },
);
if (!events.length) {
setResult({
status: 'offline',
lastSeen: null,
eventCount: 0,
error: null,
checkedAt: Date.now(),
});
return;
}
const lastSeen = Math.max(...events.map((e) => e.created_at));
const ageMs = Date.now() - lastSeen * 1000;
const status: CVMHealthStatus = ageMs <= STALE_AFTER_MS ? 'online' : 'stale';
setResult({
status,
lastSeen,
eventCount: events.length,
error: null,
checkedAt: Date.now(),
});
} catch (err) {
setResult({
status: 'error',
lastSeen: null,
eventCount: 0,
error: err instanceof Error ? err.message : 'Could not reach the ContextVM relay',
checkedAt: Date.now(),
});
}
}, [nostr]);
const reset = useCallback(() => setResult(INITIAL), []);
return { ...result, check, reset };
}

View file

@ -7,6 +7,7 @@ import { FollowListManager } from '@/components/FollowListManager';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import { SecuritySettings } from '@/components/SecuritySettings'; import { SecuritySettings } from '@/components/SecuritySettings';
import { RelatrSettings } from '@/components/RelatrSettings'; import { RelatrSettings } from '@/components/RelatrSettings';
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { LanguageSelector } from '@/components/LanguageToggle'; import { LanguageSelector } from '@/components/LanguageToggle';
import { DesktopNavManager } from '@/components/DesktopNavManager'; import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab'; import { MyDataTab } from '@/components/MyDataTab';
@ -342,6 +343,8 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
<BackHeader title="Advanced" onBack={onBack} /> <BackHeader title="Advanced" onBack={onBack} />
<div className="space-y-4"> <div className="space-y-4">
<RelatrSettings /> <RelatrSettings />
{/* Owner-only — renders null for everyone except platform owners. */}
<CVMHealthCheck />
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2"> <div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
<div className="px-4 py-3.5 border-b border-white/5"> <div className="px-4 py-3.5 border-b border-white/5">
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p> <p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>