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:
parent
148ae251ef
commit
e6517b5c70
3 changed files with 302 additions and 0 deletions
191
src/components/CVMHealthCheck.tsx
Normal file
191
src/components/CVMHealthCheck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/hooks/useCVMServerHealth.ts
Normal file
108
src/hooks/useCVMServerHealth.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { FollowListManager } from '@/components/FollowListManager';
|
|||
import { WalletModal } from '@/components/WalletModal';
|
||||
import { SecuritySettings } from '@/components/SecuritySettings';
|
||||
import { RelatrSettings } from '@/components/RelatrSettings';
|
||||
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
|
||||
import { LanguageSelector } from '@/components/LanguageToggle';
|
||||
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
||||
import { MyDataTab } from '@/components/MyDataTab';
|
||||
|
|
@ -342,6 +343,8 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
|
|||
<BackHeader title="Advanced" onBack={onBack} />
|
||||
<div className="space-y-4">
|
||||
<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="px-4 py-3.5 border-b border-white/5">
|
||||
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue