From e6517b5c70d50ef6a45eb70818bce34cbe07c7cc Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 29 Jul 2026 22:27:22 +0700 Subject: [PATCH] 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 --- src/components/CVMHealthCheck.tsx | 191 ++++++++++++++++++++++++++++++ src/hooks/useCVMServerHealth.ts | 108 +++++++++++++++++ src/pages/SettingsPage.tsx | 3 + 3 files changed, 302 insertions(+) create mode 100644 src/components/CVMHealthCheck.tsx create mode 100644 src/hooks/useCVMServerHealth.ts diff --git a/src/components/CVMHealthCheck.tsx b/src/components/CVMHealthCheck.tsx new file mode 100644 index 0000000..b5597c5 --- /dev/null +++ b/src/components/CVMHealthCheck.tsx @@ -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 ( +
+
+
+

+ + ContextVM Server + + Owner + +

+

+ Health probe for the Sasquatch CVM data server +

+
+ + + {meta.label} + +
+ +
+ {/* Status detail line */} +
+ +
+ {health.status === 'idle' && ( + + Tap “Check server” to ping the ContextVM relay for the server’s heartbeat. + + )} + {health.status === 'checking' && ( + Querying {CVM_RELAY}… + )} + {health.status === 'online' && health.lastSeen && ( + + Server is announcing. Last event {timeAgo(health.lastSeen)}. + + )} + {health.status === 'stale' && health.lastSeen && ( + + Server found, but last activity was {timeAgo(health.lastSeen)} — it may be + down or idle. + + )} + {health.status === 'offline' && ( + + No events from the server on the relay. It’s likely not running. + + )} + {health.status === 'error' && ( + {health.error} + )} +
+
+ + {/* Meta grid */} + {(health.eventCount > 0 || health.checkedAt) && ( +
+
+

+ Events found +

+

{health.eventCount}

+
+
+

+ Last seen +

+

+ {health.lastSeen ? timeAgo(health.lastSeen) : '—'} +

+
+
+ )} + + {/* Note — Sasquatch does NOT depend on this server. */} +

+ Note: Sasquatch queries data relays directly — it keeps working even if this + server is down. This check is for operations only. +

+ + {/* Actions */} +
+ + + Directory + +
+
+
+ ); +} diff --git a/src/hooks/useCVMServerHealth.ts b/src/hooks/useCVMServerHealth.ts new file mode 100644 index 0000000..c5389fd --- /dev/null +++ b/src/hooks/useCVMServerHealth.ts @@ -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/). */ +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(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 }; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 982c2c4..c6478b9 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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 }) {
+ {/* Owner-only — renders null for everyone except platform owners. */} +

🦧 Sasquatch Guide