Spaces:
Runtime error
Runtime error
Commit Β·
44737f9
1
Parent(s): cde8149
v26: live Projects + Sentinel pages, updated api.js
Browse files- website/src/lib/api.js +24 -12
- website/src/pages/Projects.jsx +242 -17
- website/src/pages/Sentinel.jsx +170 -20
website/src/lib/api.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
const API_BASE = import.meta.env.VITE_API_URL || 'https://ultron-api.ghostdriveg1.workers.dev';
|
| 2 |
const AUTH_TOKEN = import.meta.env.VITE_AUTH_TOKEN || '';
|
| 3 |
|
|
@@ -6,6 +10,7 @@ async function request(path, options = {}) {
|
|
| 6 |
...options,
|
| 7 |
headers: {
|
| 8 |
'Content-Type': 'application/json',
|
|
|
|
| 9 |
Authorization: `Bearer ${AUTH_TOKEN}`,
|
| 10 |
...options.headers,
|
| 11 |
},
|
|
@@ -15,31 +20,38 @@ async function request(path, options = {}) {
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export const api = {
|
| 18 |
-
health
|
|
|
|
| 19 |
|
|
|
|
| 20 |
infer: (payload) =>
|
| 21 |
-
request('/
|
| 22 |
|
|
|
|
| 23 |
keys: {
|
| 24 |
-
|
| 25 |
-
add: (key) => request('/api/keys', { method: 'POST', body: JSON.stringify(key) }),
|
| 26 |
-
remove: (keyId) => request(`/api/keys/${keyId}`, { method: 'DELETE' }),
|
| 27 |
},
|
| 28 |
|
|
|
|
| 29 |
sentinel: {
|
| 30 |
-
trigger: (
|
| 31 |
-
request('/
|
| 32 |
method: 'POST',
|
| 33 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
| 34 |
}),
|
| 35 |
-
reports: () => request('/api/sentinel/reports'),
|
| 36 |
},
|
| 37 |
|
|
|
|
| 38 |
memory: {
|
| 39 |
-
stm: (channelId) => request(`/
|
| 40 |
},
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
},
|
| 45 |
};
|
|
|
|
| 1 |
+
// website/src/lib/api.js
|
| 2 |
+
// All API calls proxied through CF API Worker β Brain
|
| 3 |
+
// Worker URL: https://ultron-api.ghostdriveg1.workers.dev
|
| 4 |
+
|
| 5 |
const API_BASE = import.meta.env.VITE_API_URL || 'https://ultron-api.ghostdriveg1.workers.dev';
|
| 6 |
const AUTH_TOKEN = import.meta.env.VITE_AUTH_TOKEN || '';
|
| 7 |
|
|
|
|
| 10 |
...options,
|
| 11 |
headers: {
|
| 12 |
'Content-Type': 'application/json',
|
| 13 |
+
'X-Ultron-Token': AUTH_TOKEN,
|
| 14 |
Authorization: `Bearer ${AUTH_TOKEN}`,
|
| 15 |
...options.headers,
|
| 16 |
},
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export const api = {
|
| 23 |
+
// ββ Brain health (includes pool + lifecycle + promoter status) ββββββββ
|
| 24 |
+
health: () => request('/health'),
|
| 25 |
|
| 26 |
+
// ββ Infer (direct to brain) βββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
infer: (payload) =>
|
| 28 |
+
request('/infer', { method: 'POST', body: JSON.stringify(payload) }),
|
| 29 |
|
| 30 |
+
// ββ Key pool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
keys: {
|
| 32 |
+
status: () => request('/keys'),
|
|
|
|
|
|
|
| 33 |
},
|
| 34 |
|
| 35 |
+
// ββ Sentinel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
sentinel: {
|
| 37 |
+
trigger: (eventType, payload = {}) =>
|
| 38 |
+
request('/sentinel/event', {
|
| 39 |
method: 'POST',
|
| 40 |
+
body: JSON.stringify({
|
| 41 |
+
event_type: eventType,
|
| 42 |
+
payload: { source: 'website', ...payload },
|
| 43 |
+
}),
|
| 44 |
}),
|
|
|
|
| 45 |
},
|
| 46 |
|
| 47 |
+
// ββ Memory ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
memory: {
|
| 49 |
+
stm: (channelId) => request(`/memory/stm/${channelId}`),
|
| 50 |
},
|
| 51 |
|
| 52 |
+
// ββ R&D loop history βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
rd: {
|
| 54 |
+
history: (userId, limit = 30) =>
|
| 55 |
+
request(`/rd/history/${userId}?limit=${limit}`),
|
| 56 |
},
|
| 57 |
};
|
website/src/pages/Projects.jsx
CHANGED
|
@@ -1,30 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import Card from '../components/Card';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export default function Projects() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
return (
|
| 5 |
<div className="space-y-6">
|
| 6 |
-
<div>
|
| 7 |
-
<
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
</div>
|
| 10 |
|
| 11 |
-
|
| 12 |
-
<div className="px-
|
| 13 |
-
|
| 14 |
-
<p className="text-sm font-medium text-ink">No projects yet</p>
|
| 15 |
-
<p className="text-xs text-muted mt-1">Give Ultron a task with a deadline via the brain /infer endpoint.</p>
|
| 16 |
-
<p className="text-xs text-muted mt-3 max-w-sm mx-auto leading-relaxed">
|
| 17 |
-
Each project records: what was built by deadline, autonomous R&D improvements
|
| 18 |
-
made after completion, and Sentinel's analysis.
|
| 19 |
-
</p>
|
| 20 |
</div>
|
| 21 |
-
|
| 22 |
|
| 23 |
-
<
|
| 24 |
-
<div className="
|
| 25 |
-
<p className="text-
|
| 26 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</Card>
|
| 29 |
</div>
|
| 30 |
);
|
|
|
|
| 1 |
+
// website/src/pages/Projects.jsx
|
| 2 |
+
// R&D loop history + improvement timeline
|
| 3 |
+
// Data source: GET /rd/history/{user_id} β rd_loop.py
|
| 4 |
+
|
| 5 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 6 |
+
import { api } from '../lib/api';
|
| 7 |
+
import { relativeTime } from '../lib/utils';
|
| 8 |
import Card from '../components/Card';
|
| 9 |
+
import Badge from '../components/Badge';
|
| 10 |
+
|
| 11 |
+
const GHOST_UID = '1356180323058057326';
|
| 12 |
+
|
| 13 |
+
const STATUS_VARIANT = {
|
| 14 |
+
implemented: 'green',
|
| 15 |
+
proposed: 'yellow',
|
| 16 |
+
rejected: 'red',
|
| 17 |
+
pending: 'gray',
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const STATUS_ICON = {
|
| 21 |
+
implemented: 'β',
|
| 22 |
+
proposed: 'β',
|
| 23 |
+
rejected: 'β',
|
| 24 |
+
pending: 'β·',
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
function ImprovementRow({ item }) {
|
| 28 |
+
const [expanded, setExpanded] = useState(false);
|
| 29 |
+
const status = item.status || 'pending';
|
| 30 |
+
const ts = item.implemented_at || item.proposed_at || item.created_at;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="border-b border-border last:border-0">
|
| 34 |
+
<button
|
| 35 |
+
onClick={() => setExpanded((v) => !v)}
|
| 36 |
+
className="w-full flex items-start gap-4 py-3.5 text-left hover:bg-bg/50 transition-colors px-5"
|
| 37 |
+
>
|
| 38 |
+
<span
|
| 39 |
+
className={`mt-0.5 text-xs font-mono flex-shrink-0 ${
|
| 40 |
+
status === 'implemented'
|
| 41 |
+
? 'text-status-green'
|
| 42 |
+
: status === 'rejected'
|
| 43 |
+
? 'text-status-red'
|
| 44 |
+
: 'text-status-yellow'
|
| 45 |
+
}`}
|
| 46 |
+
>
|
| 47 |
+
{STATUS_ICON[status] || 'β'}
|
| 48 |
+
</span>
|
| 49 |
+
<div className="flex-1 min-w-0">
|
| 50 |
+
<p className="text-sm font-medium text-ink leading-snug">
|
| 51 |
+
{item.title || item.summary || 'Untitled improvement'}
|
| 52 |
+
</p>
|
| 53 |
+
<p className="text-xs text-muted mt-0.5">
|
| 54 |
+
{item.category || 'general'} Β· {ts ? relativeTime(ts) : 'β'}
|
| 55 |
+
</p>
|
| 56 |
+
</div>
|
| 57 |
+
<Badge variant={STATUS_VARIANT[status] || 'gray'}>{status}</Badge>
|
| 58 |
+
<span className="text-xs text-muted ml-2 mt-0.5">{expanded ? 'β²' : 'βΌ'}</span>
|
| 59 |
+
</button>
|
| 60 |
+
{expanded && (
|
| 61 |
+
<div className="px-14 pb-4 space-y-2">
|
| 62 |
+
{item.description && (
|
| 63 |
+
<p className="text-xs text-muted leading-relaxed">{item.description}</p>
|
| 64 |
+
)}
|
| 65 |
+
{item.files_changed && item.files_changed.length > 0 && (
|
| 66 |
+
<div className="flex flex-wrap gap-1.5">
|
| 67 |
+
{item.files_changed.map((f) => (
|
| 68 |
+
<span
|
| 69 |
+
key={f}
|
| 70 |
+
className="font-mono text-[10px] bg-bg border border-border px-2 py-0.5 rounded"
|
| 71 |
+
>
|
| 72 |
+
{f}
|
| 73 |
+
</span>
|
| 74 |
+
))}
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
{item.sentinel_verdict && (
|
| 78 |
+
<p className="text-xs text-muted border-l-2 border-border pl-3 italic">
|
| 79 |
+
Sentinel: {item.sentinel_verdict}
|
| 80 |
+
</p>
|
| 81 |
+
)}
|
| 82 |
+
{item.council_synthesis && (
|
| 83 |
+
<p className="text-xs text-muted border-l-2 border-status-blue pl-3">
|
| 84 |
+
Council: {item.council_synthesis}
|
| 85 |
+
</p>
|
| 86 |
+
)}
|
| 87 |
+
</div>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function RdStatBar({ label, value, total, color }) {
|
| 94 |
+
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
| 95 |
+
return (
|
| 96 |
+
<div className="space-y-1.5">
|
| 97 |
+
<div className="flex justify-between text-[11px]">
|
| 98 |
+
<span className="text-muted font-mono">{label}</span>
|
| 99 |
+
<span className="font-mono text-ink">
|
| 100 |
+
{value}
|
| 101 |
+
<span className="text-muted">/{total}</span>
|
| 102 |
+
</span>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="h-1 bg-bg rounded-full overflow-hidden">
|
| 105 |
+
<div
|
| 106 |
+
className="h-full rounded-full transition-all duration-700"
|
| 107 |
+
style={{ width: `${pct}%`, backgroundColor: color }}
|
| 108 |
+
/>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
|
| 114 |
export default function Projects() {
|
| 115 |
+
const [data, setData] = useState(null);
|
| 116 |
+
const [loading, setLoading] = useState(true);
|
| 117 |
+
const [error, setError] = useState(null);
|
| 118 |
+
const [lastFetch, setLastFetch] = useState(null);
|
| 119 |
+
const [filter, setFilter] = useState('all');
|
| 120 |
+
|
| 121 |
+
const load = useCallback(async () => {
|
| 122 |
+
setLoading(true);
|
| 123 |
+
setError(null);
|
| 124 |
+
try {
|
| 125 |
+
const res = await api.rd.history(GHOST_UID, 50);
|
| 126 |
+
setData(res);
|
| 127 |
+
setLastFetch(new Date());
|
| 128 |
+
} catch (e) {
|
| 129 |
+
setError(e.message);
|
| 130 |
+
} finally {
|
| 131 |
+
setLoading(false);
|
| 132 |
+
}
|
| 133 |
+
}, []);
|
| 134 |
+
|
| 135 |
+
useEffect(() => {
|
| 136 |
+
load();
|
| 137 |
+
}, [load]);
|
| 138 |
+
|
| 139 |
+
const improvements = data?.improvements ?? [];
|
| 140 |
+
const rdState = data?.rd_state ?? null;
|
| 141 |
+
const total = improvements.length;
|
| 142 |
+
const implemented = improvements.filter((i) => i.status === 'implemented').length;
|
| 143 |
+
const proposed = improvements.filter((i) => i.status === 'proposed').length;
|
| 144 |
+
const rejected = improvements.filter((i) => i.status === 'rejected').length;
|
| 145 |
+
|
| 146 |
+
const filtered =
|
| 147 |
+
filter === 'all'
|
| 148 |
+
? improvements
|
| 149 |
+
: improvements.filter((i) => (i.status || 'pending') === filter);
|
| 150 |
+
|
| 151 |
return (
|
| 152 |
<div className="space-y-6">
|
| 153 |
+
<div className="flex items-center justify-between">
|
| 154 |
+
<div>
|
| 155 |
+
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
| 156 |
+
<p className="text-sm text-muted mt-0.5">
|
| 157 |
+
{lastFetch ? `Refreshed ${relativeTime(lastFetch)}` : 'Loading R&D history...'}
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
<button
|
| 161 |
+
onClick={load}
|
| 162 |
+
disabled={loading}
|
| 163 |
+
className="text-xs font-mono text-muted hover:text-ink border border-border px-3 py-1.5 rounded transition-colors disabled:opacity-40"
|
| 164 |
+
>
|
| 165 |
+
{loading ? '...' : 'β» Refresh'}
|
| 166 |
+
</button>
|
| 167 |
</div>
|
| 168 |
|
| 169 |
+
{error && (
|
| 170 |
+
<div className="border border-red-200 bg-red-50 text-status-red text-xs px-4 py-3 rounded-lg font-mono">
|
| 171 |
+
β {error} β RDLoop requires Redis + Brain running.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
</div>
|
| 173 |
+
)}
|
| 174 |
|
| 175 |
+
<div className="grid grid-cols-3 gap-4">
|
| 176 |
+
<div className="bg-surface border border-border rounded-lg p-5">
|
| 177 |
+
<p className="text-[11px] text-muted font-mono uppercase tracking-widest mb-3">Progress</p>
|
| 178 |
+
<div className="space-y-3">
|
| 179 |
+
<RdStatBar label="implemented" value={implemented} total={total || 1} color="#16A34A" />
|
| 180 |
+
<RdStatBar label="proposed" value={proposed} total={total || 1} color="#CA8A04" />
|
| 181 |
+
<RdStatBar label="rejected" value={rejected} total={total || 1} color="#DC2626" />
|
| 182 |
+
</div>
|
| 183 |
</div>
|
| 184 |
+
<div className="bg-surface border border-border rounded-lg p-5 col-span-2">
|
| 185 |
+
<p className="text-[11px] text-muted font-mono uppercase tracking-widest mb-3">R&D State</p>
|
| 186 |
+
{rdState ? (
|
| 187 |
+
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
|
| 188 |
+
{[
|
| 189 |
+
['Loop status', rdState.status || 'idle'],
|
| 190 |
+
['Proposals total', rdState.total_proposals ?? 'β'],
|
| 191 |
+
['Accept rate', rdState.accept_rate ? `${Math.round(rdState.accept_rate * 100)}%` : 'β'],
|
| 192 |
+
['Last run', rdState.last_run_at ? relativeTime(rdState.last_run_at) : 'never'],
|
| 193 |
+
['Active goal', rdState.active_goal || 'none'],
|
| 194 |
+
['Foresight signals', rdState.foresight_signal_count ?? 'β'],
|
| 195 |
+
].map(([k, v]) => (
|
| 196 |
+
<div key={k} className="flex gap-3">
|
| 197 |
+
<span className="text-[11px] font-mono text-muted w-32 flex-shrink-0">{k}</span>
|
| 198 |
+
<span className="text-[11px] font-mono text-ink truncate">{String(v)}</span>
|
| 199 |
+
</div>
|
| 200 |
+
))}
|
| 201 |
+
</div>
|
| 202 |
+
) : (
|
| 203 |
+
<p className="text-xs text-muted font-mono">
|
| 204 |
+
{loading ? 'Loading...' : 'No RD state β RDLoop requires Redis.'}
|
| 205 |
+
</p>
|
| 206 |
+
)}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div className="flex gap-2">
|
| 211 |
+
{['all', 'implemented', 'proposed', 'rejected'].map((f) => (
|
| 212 |
+
<button
|
| 213 |
+
key={f}
|
| 214 |
+
onClick={() => setFilter(f)}
|
| 215 |
+
className={`text-xs font-mono px-3 py-1.5 rounded border transition-colors ${
|
| 216 |
+
filter === f
|
| 217 |
+
? 'bg-ink text-white border-ink'
|
| 218 |
+
: 'border-border text-muted hover:text-ink hover:border-ink/30'
|
| 219 |
+
}`}
|
| 220 |
+
>
|
| 221 |
+
{f}
|
| 222 |
+
<span className="ml-1.5 opacity-60">
|
| 223 |
+
{f === 'all' ? total : f === 'implemented' ? implemented : f === 'proposed' ? proposed : rejected}
|
| 224 |
+
</span>
|
| 225 |
+
</button>
|
| 226 |
+
))}
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<Card title="Autonomous R&D Log">
|
| 230 |
+
{loading ? (
|
| 231 |
+
<div className="px-5 py-10 text-center">
|
| 232 |
+
<p className="text-xs text-muted font-mono">Loading improvements...</p>
|
| 233 |
+
</div>
|
| 234 |
+
) : filtered.length === 0 ? (
|
| 235 |
+
<div className="px-5 py-10 text-center space-y-2">
|
| 236 |
+
<p className="text-3xl text-border">β·</p>
|
| 237 |
+
<p className="text-sm font-medium text-ink">
|
| 238 |
+
{filter === 'all' ? 'No improvements yet' : `No ${filter} improvements`}
|
| 239 |
+
</p>
|
| 240 |
+
<p className="text-xs text-muted max-w-sm mx-auto leading-relaxed">
|
| 241 |
+
{filter === 'all'
|
| 242 |
+
? 'Give Ultron a task. After completion the R&D loop proposes autonomous improvements β Sentinel ranks, Council debates, best ones get implemented.'
|
| 243 |
+
: 'Switch to "all" to see everything.'}
|
| 244 |
+
</p>
|
| 245 |
+
</div>
|
| 246 |
+
) : (
|
| 247 |
+
<div>
|
| 248 |
+
{filtered.map((item, i) => (
|
| 249 |
+
<ImprovementRow key={item.id || i} item={item} />
|
| 250 |
+
))}
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
</Card>
|
| 254 |
</div>
|
| 255 |
);
|
website/src/pages/Sentinel.jsx
CHANGED
|
@@ -1,13 +1,95 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { api } from '../lib/api';
|
|
|
|
| 3 |
import Card from '../components/Card';
|
| 4 |
import Badge from '../components/Badge';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export default function Sentinel() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const [triggering, setTriggering] = useState(false);
|
| 8 |
const [triggered, setTriggered] = useState(false);
|
| 9 |
const [triggerError, setTriggerError] = useState(null);
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const triggerAudit = async () => {
|
| 12 |
setTriggering(true);
|
| 13 |
setTriggerError(null);
|
|
@@ -22,52 +104,119 @@ export default function Sentinel() {
|
|
| 22 |
}
|
| 23 |
};
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return (
|
| 26 |
<div className="space-y-6">
|
| 27 |
<div className="flex items-center justify-between">
|
| 28 |
<div>
|
| 29 |
<h1 className="text-2xl font-bold tracking-tight">Sentinel</h1>
|
| 30 |
-
<p className="text-sm text-muted mt-0.5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</div>
|
| 32 |
-
<button
|
| 33 |
-
onClick={triggerAudit}
|
| 34 |
-
disabled={triggering}
|
| 35 |
-
className="text-sm font-mono px-4 py-2 border border-border rounded hover:bg-bg transition-colors disabled:opacity-40"
|
| 36 |
-
>
|
| 37 |
-
{triggering ? '...' : triggered ? 'β Triggered' : 'Trigger Audit'}
|
| 38 |
-
</button>
|
| 39 |
</div>
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
{triggerError && (
|
| 42 |
<div className="text-xs font-mono text-status-red border border-red-200 bg-red-50 px-4 py-3 rounded-lg">
|
| 43 |
-
β {triggerError}
|
| 44 |
</div>
|
| 45 |
)}
|
| 46 |
|
| 47 |
<div className="grid grid-cols-3 gap-4">
|
| 48 |
{[
|
| 49 |
-
{
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
<div key={label} className="bg-surface border border-border rounded-lg p-5">
|
| 54 |
<p className="text-[11px] text-muted font-mono uppercase tracking-widest mb-2">{label}</p>
|
| 55 |
-
<
|
|
|
|
|
|
|
|
|
|
| 56 |
<p className="text-xs text-muted mt-1">{sub}</p>
|
| 57 |
</div>
|
| 58 |
))}
|
| 59 |
</div>
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<Card title="Trigger Schedule">
|
| 62 |
<div className="px-5 py-5 space-y-0">
|
| 63 |
{[
|
| 64 |
-
{ when: 'Every
|
| 65 |
-
{ when: 'Every failure', what: '
|
| 66 |
-
{ when: '
|
| 67 |
{ when: 'Project start', what: 'Reads brief β writes operational plan: DevOps, memory arch, MOA config, tool assignments.' },
|
| 68 |
].map(({ when, what }) => (
|
| 69 |
-
<div key={when} className="flex gap-5 py-3 border-b border-border last:border-0">
|
| 70 |
-
<div className="w-
|
| 71 |
<div className="text-xs text-muted leading-relaxed">{what}</div>
|
| 72 |
</div>
|
| 73 |
))}
|
|
@@ -78,6 +227,7 @@ export default function Sentinel() {
|
|
| 78 |
<Card title="Incident Log">
|
| 79 |
<div className="px-5 py-6 text-center">
|
| 80 |
<p className="text-xs text-muted font-mono">No incidents. System stable.</p>
|
|
|
|
| 81 |
</div>
|
| 82 |
</Card>
|
| 83 |
<Card title="Weekly Reports">
|
|
|
|
| 1 |
+
// website/src/pages/Sentinel.jsx
|
| 2 |
+
// God Layer dashboard: SpacePromoter live topology + audit trigger
|
| 3 |
+
// Data: GET /health (promoter field, refreshed every 30s)
|
| 4 |
+
|
| 5 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 6 |
import { api } from '../lib/api';
|
| 7 |
+
import { relativeTime } from '../lib/utils';
|
| 8 |
import Card from '../components/Card';
|
| 9 |
import Badge from '../components/Badge';
|
| 10 |
|
| 11 |
+
function NodeCard({ name, role, url, isAlive, lastStatus, consecutiveFailures, consecutiveSuccesses, lastCheckAt }) {
|
| 12 |
+
const ok = lastStatus === 'ok';
|
| 13 |
+
const unknown = lastStatus === 'unknown';
|
| 14 |
+
return (
|
| 15 |
+
<div className="bg-surface border border-border rounded-lg p-5">
|
| 16 |
+
<div className="flex items-center justify-between mb-3">
|
| 17 |
+
<div className="flex items-center gap-2">
|
| 18 |
+
<span
|
| 19 |
+
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
| 20 |
+
unknown ? 'bg-border' : ok ? 'bg-status-green' : 'bg-status-red'
|
| 21 |
+
}`}
|
| 22 |
+
/>
|
| 23 |
+
<span className="text-xs font-mono font-medium text-ink">{name}</span>
|
| 24 |
+
</div>
|
| 25 |
+
<Badge variant={role === 'primary' ? 'green' : role === 'backup' ? 'yellow' : 'gray'}>
|
| 26 |
+
{role}
|
| 27 |
+
</Badge>
|
| 28 |
+
</div>
|
| 29 |
+
<p className="text-[11px] font-mono text-muted truncate mb-3" title={url}>
|
| 30 |
+
{url || 'β'}
|
| 31 |
+
</p>
|
| 32 |
+
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
| 33 |
+
<div>
|
| 34 |
+
<p className="text-[10px] text-muted font-mono">STATUS</p>
|
| 35 |
+
<p className={`text-xs font-mono font-medium ${
|
| 36 |
+
ok ? 'text-status-green' : unknown ? 'text-muted' : 'text-status-red'
|
| 37 |
+
}`}>
|
| 38 |
+
{lastStatus}
|
| 39 |
+
</p>
|
| 40 |
+
</div>
|
| 41 |
+
<div>
|
| 42 |
+
<p className="text-[10px] text-muted font-mono">ALIVE</p>
|
| 43 |
+
<p className={`text-xs font-mono font-medium ${isAlive ? 'text-status-green' : 'text-status-red'}`}>
|
| 44 |
+
{isAlive ? 'yes' : 'no'}
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
<div>
|
| 48 |
+
<p className="text-[10px] text-muted font-mono">FAILS</p>
|
| 49 |
+
<p className={`text-xs font-mono font-medium ${consecutiveFailures > 0 ? 'text-status-red' : 'text-ink'}`}>
|
| 50 |
+
{consecutiveFailures}
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<p className="text-[10px] text-muted font-mono">OK STREAK</p>
|
| 55 |
+
<p className="text-xs font-mono font-medium text-ink">{consecutiveSuccesses}</p>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
{lastCheckAt && (
|
| 59 |
+
<p className="text-[10px] text-muted font-mono mt-3">Checked {relativeTime(lastCheckAt)}</p>
|
| 60 |
+
)}
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
export default function Sentinel() {
|
| 66 |
+
const [health, setHealth] = useState(null);
|
| 67 |
+
const [loading, setLoading] = useState(true);
|
| 68 |
+
const [error, setError] = useState(null);
|
| 69 |
+
const [lastFetch, setLastFetch] = useState(null);
|
| 70 |
const [triggering, setTriggering] = useState(false);
|
| 71 |
const [triggered, setTriggered] = useState(false);
|
| 72 |
const [triggerError, setTriggerError] = useState(null);
|
| 73 |
|
| 74 |
+
const fetchHealth = useCallback(async () => {
|
| 75 |
+
try {
|
| 76 |
+
const data = await api.health();
|
| 77 |
+
setHealth(data);
|
| 78 |
+
setLastFetch(new Date());
|
| 79 |
+
setError(null);
|
| 80 |
+
} catch (e) {
|
| 81 |
+
setError(e.message);
|
| 82 |
+
} finally {
|
| 83 |
+
setLoading(false);
|
| 84 |
+
}
|
| 85 |
+
}, []);
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
fetchHealth();
|
| 89 |
+
const iv = setInterval(fetchHealth, 30_000);
|
| 90 |
+
return () => clearInterval(iv);
|
| 91 |
+
}, [fetchHealth]);
|
| 92 |
+
|
| 93 |
const triggerAudit = async () => {
|
| 94 |
setTriggering(true);
|
| 95 |
setTriggerError(null);
|
|
|
|
| 104 |
}
|
| 105 |
};
|
| 106 |
|
| 107 |
+
const promoter = health?.promoter ?? null;
|
| 108 |
+
const promoterActive = health?.promoter_active ?? false;
|
| 109 |
+
const sentinelActive = health?.sentinel_active ?? false;
|
| 110 |
+
const nodes = promoter?.nodes ? Object.values(promoter.nodes) : [];
|
| 111 |
+
const failoverCount = promoter?.failover_count ?? 0;
|
| 112 |
+
const inDegraded = promoter?.in_degraded_state ?? false;
|
| 113 |
+
|
| 114 |
return (
|
| 115 |
<div className="space-y-6">
|
| 116 |
<div className="flex items-center justify-between">
|
| 117 |
<div>
|
| 118 |
<h1 className="text-2xl font-bold tracking-tight">Sentinel</h1>
|
| 119 |
+
<p className="text-sm text-muted mt-0.5">
|
| 120 |
+
{lastFetch ? `Refreshed ${relativeTime(lastFetch)}` : 'Connecting...'}
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
<div className="flex items-center gap-2">
|
| 124 |
+
<button
|
| 125 |
+
onClick={fetchHealth}
|
| 126 |
+
disabled={loading}
|
| 127 |
+
className="text-xs font-mono text-muted hover:text-ink border border-border px-3 py-1.5 rounded transition-colors disabled:opacity-40"
|
| 128 |
+
>
|
| 129 |
+
{loading ? '...' : 'β»'}
|
| 130 |
+
</button>
|
| 131 |
+
<button
|
| 132 |
+
onClick={triggerAudit}
|
| 133 |
+
disabled={triggering}
|
| 134 |
+
className="text-sm font-mono px-4 py-2 border border-border rounded hover:bg-bg transition-colors disabled:opacity-40"
|
| 135 |
+
>
|
| 136 |
+
{triggering ? '...' : triggered ? 'β Triggered' : 'Trigger Audit'}
|
| 137 |
+
</button>
|
| 138 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
</div>
|
| 140 |
|
| 141 |
+
{error && (
|
| 142 |
+
<div className="text-xs font-mono text-status-red border border-red-200 bg-red-50 px-4 py-3 rounded-lg">
|
| 143 |
+
β {error}
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
{triggerError && (
|
| 147 |
<div className="text-xs font-mono text-status-red border border-red-200 bg-red-50 px-4 py-3 rounded-lg">
|
| 148 |
+
β Trigger failed: {triggerError}
|
| 149 |
</div>
|
| 150 |
)}
|
| 151 |
|
| 152 |
<div className="grid grid-cols-3 gap-4">
|
| 153 |
{[
|
| 154 |
+
{
|
| 155 |
+
label: 'God Layer',
|
| 156 |
+
value: sentinelActive ? 'Active' : 'Inactive',
|
| 157 |
+
sub: sentinelActive ? 'Gemini 2.5 Pro Β· dedicated key' : 'Set GEMINI_SENTINEL_KEY in HF Space',
|
| 158 |
+
ok: sentinelActive,
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
label: 'SpacePromoter',
|
| 162 |
+
value: promoterActive ? (inDegraded ? 'Degraded' : `${nodes.length} node${nodes.length !== 1 ? 's' : ''}`) : 'Inactive',
|
| 163 |
+
sub: promoterActive
|
| 164 |
+
? `${failoverCount} failover${failoverCount !== 1 ? 's' : ''} Β· 30s check interval`
|
| 165 |
+
: 'No Redis or init failed',
|
| 166 |
+
ok: promoterActive && !inDegraded,
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
label: 'Weekly Audit',
|
| 170 |
+
value: 'Sunday 23:59',
|
| 171 |
+
sub: 'GitHub Actions cron β /sentinel/event',
|
| 172 |
+
ok: true,
|
| 173 |
+
},
|
| 174 |
+
].map(({ label, value, sub, ok }) => (
|
| 175 |
<div key={label} className="bg-surface border border-border rounded-lg p-5">
|
| 176 |
<p className="text-[11px] text-muted font-mono uppercase tracking-widest mb-2">{label}</p>
|
| 177 |
+
<div className="flex items-center gap-2">
|
| 178 |
+
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${ok ? 'bg-status-green' : 'bg-status-red'}`} />
|
| 179 |
+
<p className="text-base font-bold text-ink">{value}</p>
|
| 180 |
+
</div>
|
| 181 |
<p className="text-xs text-muted mt-1">{sub}</p>
|
| 182 |
</div>
|
| 183 |
))}
|
| 184 |
</div>
|
| 185 |
|
| 186 |
+
{promoterActive && nodes.length > 0 && (
|
| 187 |
+
<div>
|
| 188 |
+
<h2 className="text-xs font-mono text-muted uppercase tracking-widest mb-3">Live Space Topology</h2>
|
| 189 |
+
<div className={`grid gap-4 ${nodes.length === 1 ? 'grid-cols-1' : nodes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
| 190 |
+
{nodes.map((node) => (
|
| 191 |
+
<NodeCard key={node.name} {...node} />
|
| 192 |
+
))}
|
| 193 |
+
</div>
|
| 194 |
+
{inDegraded && (
|
| 195 |
+
<div className="mt-3 text-xs font-mono text-status-red border border-red-200 bg-red-50 px-4 py-3 rounded-lg">
|
| 196 |
+
π¨ DEGRADED β max failovers reached or all nodes down. Manual intervention required.
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{promoterActive && nodes.length === 0 && (
|
| 203 |
+
<Card title="Space Topology">
|
| 204 |
+
<div className="px-5 py-6 text-center">
|
| 205 |
+
<p className="text-xs text-muted font-mono">No nodes. Set BRAIN_PRIMARY_URL env var.</p>
|
| 206 |
+
</div>
|
| 207 |
+
</Card>
|
| 208 |
+
)}
|
| 209 |
+
|
| 210 |
<Card title="Trigger Schedule">
|
| 211 |
<div className="px-5 py-5 space-y-0">
|
| 212 |
{[
|
| 213 |
+
{ when: 'Every 30s', what: 'SpacePromoter pings all Space /health endpoints. 3 consecutive failures β backup promoted β CF KV updated.' },
|
| 214 |
+
{ when: 'Every failure', what: 'Sentinel instant analysis β Notion incident page β KV rerouting β Discord DM.' },
|
| 215 |
+
{ when: 'Sunday 23:59', what: '1M context window reads full week logs β structured report β Notion + Discord. Triggered by GitHub Actions.' },
|
| 216 |
{ when: 'Project start', what: 'Reads brief β writes operational plan: DevOps, memory arch, MOA config, tool assignments.' },
|
| 217 |
].map(({ when, what }) => (
|
| 218 |
+
<div key={when} className="flex gap-5 py-3.5 border-b border-border last:border-0">
|
| 219 |
+
<div className="w-36 text-xs font-mono text-ink font-medium flex-shrink-0 pt-0.5">{when}</div>
|
| 220 |
<div className="text-xs text-muted leading-relaxed">{what}</div>
|
| 221 |
</div>
|
| 222 |
))}
|
|
|
|
| 227 |
<Card title="Incident Log">
|
| 228 |
<div className="px-5 py-6 text-center">
|
| 229 |
<p className="text-xs text-muted font-mono">No incidents. System stable.</p>
|
| 230 |
+
<p className="text-xs text-muted mt-1">SpacePromoter events appear here in real time.</p>
|
| 231 |
</div>
|
| 232 |
</Card>
|
| 233 |
<Card title="Weekly Reports">
|