diff --git a/apps/website/src/main.tsx b/apps/website/src/main.tsx index 57f52c4e11..9413cbf913 100644 --- a/apps/website/src/main.tsx +++ b/apps/website/src/main.tsx @@ -24,6 +24,7 @@ const Licensing = lazy(() => import('./pages/Licensing.tsx')) const Proof = lazy(() => import('./pages/Proof.tsx')) const AboutAuthor = lazy(() => import('./pages/AboutAuthor.tsx')) const Resources = lazy(() => import('./pages/Resources.tsx')) +const Queen = lazy(() => import('./pages/Queen.tsx')) // Blog exports two components rather than a default, so the module has to be // unwrapped into the shape lazy() expects. const BlogIndex = lazy(() => import('./pages/Blog.tsx').then(m => ({ default: m.BlogIndex }))) @@ -56,6 +57,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css new file mode 100644 index 0000000000..3c6e608507 --- /dev/null +++ b/apps/website/src/pages/Queen.css @@ -0,0 +1,162 @@ +/* Scoped under .queen-page on purpose. The queen came from a standalone app + whose stylesheet styled bare tags and generic names like .tab, .metric-card + and .loading. Dropping those into this site would have restyled pages that + have nothing to do with the queen. */ + +.queen-page { + max-width: 1100px; + margin: 0 auto; + padding: 3rem 1.25rem 5rem; + color: var(--text, #e8e8ea); +} + +.queen-header h1 { + margin: 0 0 0.25rem; + font-size: clamp(1.8rem, 4vw, 2.6rem); +} + +.queen-header p { + margin: 0 0 2rem; + color: var(--text-muted, #9a9aa2); +} + +.queen-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.queen-tabs button { + padding: 0.55rem 1rem; + border: 1px solid var(--border, #2a2a32); + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.queen-tabs button:hover { border-color: var(--accent, #7c5cff); } + +.queen-tabs button.active { + border-color: var(--accent, #7c5cff); + background: color-mix(in srgb, var(--accent, #7c5cff) 14%, transparent); +} + +.queen-offline { + border: 1px solid #ff9800; + border-radius: 10px; + padding: 1.1rem 1.25rem; + margin-bottom: 1.75rem; + background: color-mix(in srgb, #ff9800 8%, transparent); +} + +.queen-offline h3 { margin: 0 0 0.5rem; } +.queen-offline p { margin: 0 0 0.55rem; line-height: 1.55; } +.queen-offline code { + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + font-size: 0.9em; +} +.queen-offline-detail { color: var(--text-muted, #9a9aa2); } +.queen-offline-note { color: var(--text-muted, #9a9aa2); font-size: 0.92rem; margin-bottom: 0; } + +.queen-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +.queen-card { + border: 1px solid var(--border, #2a2a32); + border-radius: 10px; + padding: 1.25rem; + background: var(--surface, rgba(255, 255, 255, 0.02)); +} + +.queen-card h2 { margin: 0 0 0.75rem; font-size: 1.15rem; } +.queen-sub { color: var(--text-muted, #9a9aa2); margin: 0 0 1rem; line-height: 1.55; } + +.queen-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; +} + +.queen-metric { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.75rem; + border: 1px solid var(--border, #2a2a32); + border-radius: 8px; +} + +.queen-metric-label { font-size: 0.78rem; color: var(--text-muted, #9a9aa2); } +.queen-metric-value { font-size: 1.05rem; font-variant-numeric: tabular-nums; } + +.queen-button { + padding: 0.65rem 1.15rem; + border: 1px solid var(--accent, #7c5cff); + border-radius: 8px; + background: color-mix(in srgb, var(--accent, #7c5cff) 18%, transparent); + color: inherit; + font: inherit; + cursor: pointer; +} + +.queen-button:disabled { + opacity: 0.45; + cursor: not-allowed; + border-color: var(--border, #2a2a32); + background: transparent; +} + +.queen-result { + margin-top: 1rem; + padding: 0.85rem 1rem; + border-radius: 8px; + border: 1px solid var(--border, #2a2a32); + line-height: 1.5; +} + +.queen-result.success { border-color: #4caf50; } +.queen-result.failure { border-color: #e05252; } + +.queen-result-rows { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.5rem; + font-size: 0.9rem; + color: var(--text-muted, #9a9aa2); +} + +.queen-episodes { display: flex; flex-direction: column; gap: 0.75rem; } + +.queen-episode { + border: 1px solid var(--border, #2a2a32); + border-left-width: 3px; + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.queen-episode.success { border-left-color: #4caf50; } +.queen-episode.failure { border-left-color: #e05252; } + +.queen-episode header, +.queen-episode footer { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.82rem; + color: var(--text-muted, #9a9aa2); +} + +.queen-episode p { margin: 0.4rem 0; line-height: 1.5; } +.queen-episode .ok { color: #4caf50; } +.queen-episode .bad { color: #e05252; } diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx new file mode 100644 index 0000000000..ec6ca11e54 --- /dev/null +++ b/apps/website/src/pages/Queen.tsx @@ -0,0 +1,302 @@ +import { useEffect, useState } from 'react' +import './Queen.css' + +// The queen's panels read a backend that this site does not contain. t27.ai is +// static hosting; there is no /health and no /api here. The original app in +// apps/queen-web assumed same-origin and called res.json() on whatever came +// back, so on static hosting every panel silently rendered '...' forever -- a +// dashboard that looks alive and reports nothing. +// +// So the base URL is explicit, and "no backend" is a state the page SHOWS +// rather than a state it hides: +// - VITE_QUEEN_API set at build time -> that origin +// - localhost -> http://localhost:8080 +// - anything else -> unset, and the page says so +const ENV_API = (import.meta.env.VITE_QUEEN_API as string | undefined)?.replace(/\/+$/, '') +const IS_LOCAL = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname) +const QUEEN_API = ENV_API ?? (IS_LOCAL ? 'http://localhost:8080' : null) + +type ProbeState = 'loading' | 'ok' | 'unreachable' + +interface Probe { + state: ProbeState + data: T | null + error: string | null +} + +/** Fetch that reports what actually happened instead of throwing it away. */ +async function readJson(path: string, init?: RequestInit): Promise { + if (!QUEEN_API) throw new Error('no backend configured') + const res = await fetch(QUEEN_API + path, init) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + // A static host answers /api/status with an HTML 200 for its SPA fallback. + // Parsing that as JSON throws a syntax error that reads like a bug in the + // queen; checking the type first names the real cause. + const type = res.headers.get('content-type') ?? '' + if (!type.includes('json')) throw new Error(`expected JSON, got ${type.split(';')[0] || 'nothing'}`) + return res.json() as Promise +} + +function useProbe(path: string, intervalMs: number): Probe { + const [probe, setProbe] = useState>({ state: 'loading', data: null, error: null }) + + useEffect(() => { + if (!QUEEN_API) { + setProbe({ state: 'unreachable', data: null, error: 'no backend configured' }) + return + } + let alive = true + const tick = async () => { + try { + const data = await readJson(path) + if (alive) setProbe({ state: 'ok', data, error: null }) + } catch (e) { + if (alive) setProbe({ state: 'unreachable', data: null, error: (e as Error).message }) + } + } + void tick() + const id = setInterval(tick, intervalMs) + return () => { alive = false; clearInterval(id) } + }, [path, intervalMs]) + + return probe +} + +// These are the shapes the brain ACTUALLY returns, read off +// src/background_agent/server.zig rather than assumed. +// +// The version of this page in apps/queen-web rendered five metrics -- +// trinity_signature, improve_cycles, uptime_seconds, env_status, swarm_active -- +// and called /api/status, /api/episodes, /api/improve and /api/pipeline. The +// brain serves NONE of those. /health returns exactly {"status":"ok"}, and the +// only /api routes that exist are /api/containers and /api/sessions. +// +// So those five metrics could never be filled, by any deployment, ever. A panel +// whose value is structurally unobtainable is worse than a missing panel: it +// reads as "not yet" when the truth is "not ever". + +interface HealthResponse { + status: string +} + +interface Container { + id: string + name: string + status: string + railway_service_id?: string +} + +interface Session { + id: string + name?: string + status?: string +} + +function BrainOffline({ reason }: { reason: string | null }) { + return ( +
+

The brain is not connected

+

+ This page is the queen's face. Her brain is a separate service that answers{' '} + /health, /api/containers and /api/sessions. + This site is static hosting and serves none of them. +

+

+ {QUEEN_API + ? <>Tried {QUEEN_API} โ€” {reason} + : <>No address configured. Build with VITE_QUEEN_API=https://your-brain.example to point this page at a running brain.} +

+

+ Nothing below is live. Empty panels would have looked the same as a healthy + queen with nothing to report, so the page says which one it is. +

+
+ ) +} + +function MetricCard({ label, value, status }: { label: string; value: string | number; status?: string }) { + const colour = status === 'active' ? '#4caf50' : status === 'degraded' ? '#ff9800' : undefined + return ( +
+ {label} + {value} +
+ ) +} + +function StatusDashboard({ health }: { health: Probe }) { + const dash = 'โ€”' + return ( +
+

๐Ÿ‘‘ Liveness

+
+ + +
+

+ /health is the only endpoint the brain answers without a token, + and it returns liveness alone โ€” no counters, no uptime. Anything more + specific here would be invented rather than measured. +

+
+ ) +} + +function ImprovementPanel() { + // The button used to POST /api/improve. That route does not exist in + // src/background_agent/server.zig -- the brain's whole surface is /health, + // /api/containers and /api/sessions. A button that always fails is worse + // than no button, so this states the gap instead of pretending at it. + return ( +
+

๐Ÿ”„ Self-improvement

+

+ The queen's original panel triggered a cycle through POST /api/improve. + The brain does not implement that route, so the control is not shown: it could + only ever have returned an error. +

+

+ Restoring it means adding the route to src/background_agent/server.zig, + which is a change to the brain rather than to this page. +

+
+ ) +} + +/** The two collections the brain really exposes. Both sit behind a token. */ +function BrainInventory() { + const containers = useProbe('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/api/containers', 20000) + const sessions = useProbe('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/api/sessions', 20000) + + // The original called episodes?.slice(0, 10) on whatever came back; an object + // rather than an array crashed the page on that line. Shape is checked first. + const asList = (p: Probe) => (Array.isArray(p.data) ? p.data : []) + + const renderState = (p: Probe, what: string) => { + if (p.state === 'loading') return

Loading {what}โ€ฆ

+ if (p.state === 'unreachable') { + const locked = p.error === 'HTTP 401' + return ( +

+ {locked + ? <>Requires a token. /api/{what} is authenticated โ€” this page holds no credentials and does not ask for any. + : <>No {what}: {p.error}} +

+ ) + } + return null + } + + return ( + <> +
+

๐Ÿ“ฆ Containers

+ {renderState(containers, 'containers')} +
+ {asList(containers).slice(0, 10).map(c => ( +
+
+ {c.id.slice(0, 8)} + {c.status} +
+

{c.name}

+ {c.railway_service_id &&
railway: {c.railway_service_id.slice(0, 8)}
} +
+ ))} +
+
+ +
+

๐Ÿงต Sessions

+ {renderState(sessions, 'sessions')} +
+ {asList(sessions).slice(0, 10).map(s => ( +
+
+ {s.id.slice(0, 8)} + {s.status ?? ''} +
+ {s.name &&

{s.name}

} +
+ ))} +
+
+ + ) +} + +type Kingdom = 'brain' | 'body' | 'spirit' + +const KINGDOMS: { key: Kingdom; label: string }[] = [ + { key: 'brain', label: '๐Ÿง  Brain (Strand I)' }, + { key: 'body', label: '๐Ÿ’ช Body (Strand II)' }, + { key: 'spirit', label: '๐Ÿ”ฎ Spirit (Strand III)' }, +] + +export default function Queen() { + // In the original these three tabs were and the state + // setter was never called, so the highlight sat on "brain" whatever you + // clicked and nothing read the query string. They are buttons now, and the + // selection is the thing the page actually renders from. + const [kingdom, setKingdom] = useState('brain') + const health = useProbe('/health', 15000) + const connected = health.state === 'ok' + + return ( +
+
+

๐Ÿ‘‘ Queen Trinity

+

Self-improving container ยท ฯ†ยฒ + 1/ฯ†ยฒ = 3

+
+ + + + {!connected && } + + {kingdom === 'brain' && ( + <> +
+ + +
+ + + )} + + {kingdom === 'body' && ( +
+

๐Ÿ’ช Body

+

+ The body is the hardware strand โ€” FPGA fleet and silicon. It has no + endpoint on the queen's brain yet, so there is nothing here to read. +

+
+ )} + + {kingdom === 'spirit' && ( +
+

๐Ÿ”ฎ Spirit

+

+ The spirit strand is the corpus and its claims. It has no endpoint on + the queen's brain yet, so there is nothing here to read. +

+
+ )} +
+ ) +}