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