jfl 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +294 -30
  2. package/dist/commands/context-hub.d.ts.map +1 -1
  3. package/dist/commands/context-hub.js +154 -0
  4. package/dist/commands/context-hub.js.map +1 -1
  5. package/dist/commands/flows.d.ts +4 -1
  6. package/dist/commands/flows.d.ts.map +1 -1
  7. package/dist/commands/flows.js +160 -1
  8. package/dist/commands/flows.js.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +42 -0
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/peter.d.ts.map +1 -1
  13. package/dist/commands/peter.js +220 -1
  14. package/dist/commands/peter.js.map +1 -1
  15. package/dist/commands/pi.d.ts +21 -0
  16. package/dist/commands/pi.d.ts.map +1 -0
  17. package/dist/commands/pi.js +154 -0
  18. package/dist/commands/pi.js.map +1 -0
  19. package/dist/commands/portfolio.d.ts.map +1 -1
  20. package/dist/commands/portfolio.js +22 -69
  21. package/dist/commands/portfolio.js.map +1 -1
  22. package/dist/commands/predict.d.ts +6 -0
  23. package/dist/commands/predict.d.ts.map +1 -0
  24. package/dist/commands/predict.js +234 -0
  25. package/dist/commands/predict.js.map +1 -0
  26. package/dist/commands/synopsis.d.ts +44 -0
  27. package/dist/commands/synopsis.d.ts.map +1 -1
  28. package/dist/commands/synopsis.js +1 -1
  29. package/dist/commands/synopsis.js.map +1 -1
  30. package/dist/commands/update.d.ts.map +1 -1
  31. package/dist/commands/update.js +49 -1
  32. package/dist/commands/update.js.map +1 -1
  33. package/dist/commands/viz.d.ts +7 -0
  34. package/dist/commands/viz.d.ts.map +1 -0
  35. package/dist/commands/viz.js +460 -0
  36. package/dist/commands/viz.js.map +1 -0
  37. package/dist/dashboard/index.d.ts +4 -5
  38. package/dist/dashboard/index.d.ts.map +1 -1
  39. package/dist/dashboard/index.js +57 -146
  40. package/dist/dashboard/index.js.map +1 -1
  41. package/dist/dashboard-static/assets/index-B6kRK9Rq.js +116 -0
  42. package/dist/dashboard-static/assets/index-BpdKJPLu.css +1 -0
  43. package/dist/dashboard-static/index.html +16 -0
  44. package/dist/index.js +112 -19
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/flow-engine.d.ts +1 -0
  47. package/dist/lib/flow-engine.d.ts.map +1 -1
  48. package/dist/lib/flow-engine.js +30 -1
  49. package/dist/lib/flow-engine.js.map +1 -1
  50. package/dist/lib/hub-client.d.ts +80 -0
  51. package/dist/lib/hub-client.d.ts.map +1 -0
  52. package/dist/lib/hub-client.js +46 -0
  53. package/dist/lib/hub-client.js.map +1 -0
  54. package/dist/lib/predictor.d.ts +99 -0
  55. package/dist/lib/predictor.d.ts.map +1 -0
  56. package/dist/lib/predictor.js +394 -0
  57. package/dist/lib/predictor.js.map +1 -0
  58. package/dist/lib/service-gtm.d.ts +86 -51
  59. package/dist/lib/service-gtm.d.ts.map +1 -1
  60. package/dist/lib/service-gtm.js +417 -242
  61. package/dist/lib/service-gtm.js.map +1 -1
  62. package/dist/lib/telemetry-agent.d.ts +57 -0
  63. package/dist/lib/telemetry-agent.d.ts.map +1 -0
  64. package/dist/lib/telemetry-agent.js +268 -0
  65. package/dist/lib/telemetry-agent.js.map +1 -0
  66. package/dist/lib/telemetry-digest.d.ts.map +1 -1
  67. package/dist/lib/telemetry-digest.js +17 -17
  68. package/dist/lib/telemetry-digest.js.map +1 -1
  69. package/dist/lib/telemetry.d.ts +1 -0
  70. package/dist/lib/telemetry.d.ts.map +1 -1
  71. package/dist/lib/telemetry.js +14 -6
  72. package/dist/lib/telemetry.js.map +1 -1
  73. package/dist/mcp/context-hub-mcp.js +0 -0
  74. package/dist/mcp/service-registry-mcp.js +0 -0
  75. package/dist/types/map.d.ts +1 -1
  76. package/dist/types/map.d.ts.map +1 -1
  77. package/dist/types/map.js.map +1 -1
  78. package/dist/utils/jfl-paths.d.ts +1 -0
  79. package/dist/utils/jfl-paths.d.ts.map +1 -1
  80. package/dist/utils/jfl-paths.js +1 -0
  81. package/dist/utils/jfl-paths.js.map +1 -1
  82. package/package.json +7 -2
  83. package/scripts/generate-changesets.sh +113 -0
  84. package/scripts/pp-branch-pr.sh +115 -0
  85. package/template/.jfl/flows-self-driving.yaml +170 -0
  86. package/dist/dashboard/components.d.ts +0 -7
  87. package/dist/dashboard/components.d.ts.map +0 -1
  88. package/dist/dashboard/components.js +0 -575
  89. package/dist/dashboard/components.js.map +0 -1
  90. package/dist/dashboard/pages.d.ts +0 -7
  91. package/dist/dashboard/pages.d.ts.map +0 -1
  92. package/dist/dashboard/pages.js +0 -1580
  93. package/dist/dashboard/pages.js.map +0 -1
  94. package/dist/dashboard/styles.d.ts +0 -7
  95. package/dist/dashboard/styles.d.ts.map +0 -1
  96. package/dist/dashboard/styles.js +0 -1110
  97. package/dist/dashboard/styles.js.map +0 -1
@@ -1,1580 +0,0 @@
1
- /**
2
- * Dashboard page components
3
- *
4
- * @purpose Command center pages: mode-aware overview, evals, portfolio, scope, service, events, agents, projects, sessions, journal
5
- */
6
- export function getPagesJS() {
7
- return `
8
- // ================================================================
9
- // HOOKS
10
- // ================================================================
11
-
12
- function useWorkspaceMode() {
13
- const [mode, setMode] = useState('gtm')
14
- const [config, setConfig] = useState({})
15
- const [children, setChildren] = useState([])
16
- const [loading, setLoading] = useState(true)
17
-
18
- useEffect(() => {
19
- apiFetch('/api/context/status')
20
- .then(data => {
21
- const t = data.type || 'standalone'
22
- setMode(t === 'standalone' ? 'gtm' : t)
23
- setConfig(data.config || {})
24
- setChildren(data.children || [])
25
- })
26
- .catch(() => {})
27
- .finally(() => setLoading(false))
28
- }, [])
29
-
30
- return { mode, config, children, loading }
31
- }
32
-
33
- function useEvalData(selectedAgent) {
34
- const [leaderboard, setLeaderboard] = useState([])
35
- const [trajectory, setTrajectory] = useState([])
36
- const [loading, setLoading] = useState(true)
37
-
38
- useEffect(() => {
39
- function load() {
40
- apiFetch('/api/eval/leaderboard')
41
- .then(data => setLeaderboard(data || []))
42
- .catch(() => setLeaderboard([]))
43
- .finally(() => setLoading(false))
44
- }
45
- load()
46
- const interval = setInterval(load, 60000)
47
- return () => clearInterval(interval)
48
- }, [])
49
-
50
- useEffect(() => {
51
- if (!selectedAgent) { setTrajectory([]); return }
52
- apiFetch('/api/eval/trajectory?agent=' + encodeURIComponent(selectedAgent) + '&metric=composite')
53
- .then(data => setTrajectory(data.points || []))
54
- .catch(() => setTrajectory([]))
55
- }, [selectedAgent])
56
-
57
- return { leaderboard, trajectory, loading }
58
- }
59
-
60
- function useEventStream() {
61
- const [events, setEvents] = useState([])
62
- const [connected, setConnected] = useState(false)
63
-
64
- useEffect(() => {
65
- apiFetch('/api/events?limit=200')
66
- .then(data => setEvents(data.events || []))
67
- .catch(() => {})
68
-
69
- const token = localStorage.getItem('jfl-token')
70
- const url = new URL('/api/events/stream', window.location.origin)
71
- url.searchParams.set('patterns', '*')
72
- if (token) url.searchParams.set('token', token)
73
- const es = new EventSource(url.toString())
74
-
75
- es.onopen = () => setConnected(true)
76
- es.onerror = () => setConnected(false)
77
-
78
- function handleEvent(e) {
79
- try {
80
- const event = JSON.parse(e.data)
81
- setEvents(prev => [event, ...prev].slice(0, 500))
82
- } catch {}
83
- }
84
-
85
- es.onmessage = handleEvent
86
- const types = [
87
- 'peter:started', 'peter:task-selected', 'peter:task-completed', 'peter:all-complete',
88
- 'task:started', 'task:completed', 'task:failed',
89
- 'session:started', 'session:ended',
90
- 'service:healthy', 'service:unhealthy',
91
- 'journal:entry', 'decision:made',
92
- 'build:completed', 'deploy:completed',
93
- 'openclaw:tag', 'custom'
94
- ]
95
- types.forEach(t => es.addEventListener(t, handleEvent))
96
-
97
- return () => es.close()
98
- }, [])
99
-
100
- return { events, connected }
101
- }
102
-
103
- function useContextData() {
104
- const [journal, setJournal] = useState([])
105
- const [knowledge, setKnowledge] = useState([])
106
- const [loading, setLoading] = useState(true)
107
-
108
- useEffect(() => {
109
- apiFetch('/api/context', {
110
- method: 'POST',
111
- body: JSON.stringify({ maxItems: 100 })
112
- })
113
- .then(data => {
114
- const items = data.items || []
115
- setJournal(items.filter(i => i.source === 'journal'))
116
- setKnowledge(items.filter(i => i.source === 'knowledge'))
117
- })
118
- .catch(() => {})
119
- .finally(() => setLoading(false))
120
- }, [])
121
-
122
- return { journal, knowledge, loading }
123
- }
124
-
125
- function useTelemetryDigest(hours) {
126
- const [digest, setDigest] = useState(null)
127
- const [loading, setLoading] = useState(true)
128
-
129
- useEffect(() => {
130
- function load() {
131
- apiFetch('/api/telemetry/digest?hours=' + (hours || 168))
132
- .then(data => setDigest(data))
133
- .catch(() => setDigest(null))
134
- .finally(() => setLoading(false))
135
- }
136
- load()
137
- const interval = setInterval(load, 120000)
138
- return () => clearInterval(interval)
139
- }, [hours])
140
-
141
- return { digest, loading }
142
- }
143
-
144
- function useFlowData() {
145
- const [flows, setFlows] = useState([])
146
- const [executions, setExecutions] = useState([])
147
- const [loading, setLoading] = useState(true)
148
-
149
- useEffect(() => {
150
- function load() {
151
- Promise.all([
152
- apiFetch('/api/flows').catch(() => []),
153
- apiFetch('/api/flows/executions').catch(() => ({ executions: [] }))
154
- ]).then(([flowsData, execData]) => {
155
- setFlows(flowsData || [])
156
- setExecutions(execData.executions || execData || [])
157
- }).finally(() => setLoading(false))
158
- }
159
- load()
160
- const interval = setInterval(load, 15000)
161
- return () => clearInterval(interval)
162
- }, [])
163
-
164
- return { flows, executions, loading }
165
- }
166
-
167
- function extractAgents(events) {
168
- const agents = new Map()
169
- for (const e of events) {
170
- const src = e.source || ''
171
- if (!src) continue
172
-
173
- if (!agents.has(src)) {
174
- agents.set(src, {
175
- name: src,
176
- model: e.data?.model || e.data?.modelTier || null,
177
- role: e.data?.role || e.data?.agentRole || null,
178
- runtime: e.data?.runtime || null,
179
- status: 'idle',
180
- lastEvent: e.type,
181
- lastSeen: e.ts || e.timestamp,
182
- eventCount: 0,
183
- recentEvents: [],
184
- startedAt: null,
185
- currentTask: null,
186
- })
187
- }
188
-
189
- const agent = agents.get(src)
190
- agent.eventCount++
191
- agent.lastEvent = e.type
192
- agent.lastSeen = e.ts || e.timestamp || agent.lastSeen
193
-
194
- if (agent.recentEvents.length < 20) {
195
- agent.recentEvents.push(e)
196
- }
197
-
198
- if (e.data?.model) agent.model = e.data.model
199
- if (e.data?.modelTier) agent.model = e.data.modelTier
200
- if (e.data?.role) agent.role = e.data.role
201
- if (e.data?.agentRole) agent.role = e.data.agentRole
202
- if (e.data?.runtime) agent.runtime = e.data.runtime
203
-
204
- if (e.type?.includes('started') || e.type?.includes('start')) {
205
- agent.status = 'active'
206
- agent.startedAt = e.ts || e.timestamp
207
- } else if (e.type?.includes('completed') || e.type?.includes('complete') || e.type?.includes('ended')) {
208
- agent.status = 'idle'
209
- } else if (e.type?.includes('failed')) {
210
- agent.status = 'error'
211
- }
212
-
213
- if (e.type === 'peter:task-selected' || e.type === 'task:started') {
214
- agent.currentTask = e.data?.task || e.data?.message || e.data?.title || null
215
- }
216
- if (e.type === 'peter:task-completed' || e.type === 'task:completed') {
217
- agent.currentTask = null
218
- }
219
- }
220
- return [...agents.values()].sort((a, b) => {
221
- if (a.status === 'active' && b.status !== 'active') return -1
222
- if (b.status === 'active' && a.status !== 'active') return 1
223
- return (b.eventCount || 0) - (a.eventCount || 0)
224
- })
225
- }
226
-
227
- // ================================================================
228
- // PORTFOLIO OVERVIEW (portfolio mode)
229
- // ================================================================
230
-
231
- function PortfolioOverviewPage({ children, config }) {
232
- const { events, connected } = useEventStream()
233
- const { leaderboard, loading: evalLoading } = useEvalData()
234
- const [childHealth, setChildHealth] = useState(children || [])
235
-
236
- const enrichedChildren = (childHealth || []).map(child => {
237
- const agentsForChild = leaderboard.filter(a => a.agent.toLowerCase().includes(child.name.toLowerCase()))
238
- const bestAgent = agentsForChild[0]
239
- return {
240
- ...child,
241
- composite: bestAgent?.composite ?? null,
242
- delta: bestAgent?.delta ?? null,
243
- sparkline: bestAgent?.trajectory ?? [],
244
- activeSessions: null,
245
- }
246
- })
247
-
248
- const totalProducts = enrichedChildren.length
249
- const activeSessions = events.filter(e => (e.type || '').includes('session:started')).length
250
- const eventRate = events.length > 0 ? Math.round(events.length / Math.max(1, (Date.now() - new Date(events[events.length - 1]?.timestamp || Date.now()).getTime()) / 3600000)) : 0
251
- const avgComposite = leaderboard.length > 0 ? (leaderboard.reduce((sum, a) => sum + (a.composite || 0), 0) / leaderboard.length) : 0
252
-
253
- return html\`
254
- <div>
255
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
256
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Portfolio Overview</h2>
257
- <\${LiveIndicator} connected=\${connected} />
258
- </div>
259
-
260
- <div class="grid-4" style="margin-bottom: 1.5rem;">
261
- <\${MetricCard} value=\${totalProducts} label="Total Products" />
262
- <\${MetricCard} value=\${activeSessions} label="Active Sessions" color="var(--success)" />
263
- <\${MetricCard} value=\${eventRate} label="Events/hr" color="var(--info)" />
264
- <\${MetricCard} value=\${avgComposite > 0 ? avgComposite.toFixed(3) : '—'} label="Avg Composite" color="var(--accent)" />
265
- </div>
266
-
267
- \${enrichedChildren.length > 0 && html\`
268
- <\${Card} title="Health Grid">
269
- <div class="health-grid">
270
- \${enrichedChildren.map(child => html\`
271
- <\${HealthCard} key=\${child.name} child=\${child} />
272
- \`)}
273
- </div>
274
- <//>
275
- \`}
276
-
277
- \${leaderboard.length > 0 && html\`
278
- <\${Card} title="Leaderboard">
279
- <table>
280
- <thead>
281
- <tr>
282
- <th style="width: 40px;">#</th>
283
- <th>Agent</th>
284
- <th style="width: 100px;">Composite</th>
285
- <th style="width: 80px;">Trend</th>
286
- <th style="width: 80px;">Delta</th>
287
- <th style="width: 120px;">Model</th>
288
- <th style="width: 100px;">Last Run</th>
289
- </tr>
290
- </thead>
291
- <tbody>
292
- \${leaderboard.map((a, i) => html\`
293
- <tr key=\${a.agent}>
294
- <td style=\${'font-weight: 700; color:' + (i === 0 ? 'var(--accent)' : 'var(--muted-foreground)')}>\${i + 1}</td>
295
- <td style="font-weight: 600;">\${a.agent}</td>
296
- <td style="font-family: monospace; color: var(--accent);">\${a.composite != null ? a.composite.toFixed(4) : '—'}</td>
297
- <td><\${Sparkline} data=\${a.trajectory} width=\${60} height=\${20} /></td>
298
- <td>\${a.delta != null ? html\`<\${DeltaBadge} delta=\${a.delta} />\` : '—'}</td>
299
- <td style="font-size: 0.75rem; color: var(--muted-foreground);">\${a.model_version || '—'}</td>
300
- <td style="font-size: 0.75rem; color: var(--dim);">\${a.lastTs ? new Date(a.lastTs).toLocaleDateString() : '—'}</td>
301
- </tr>
302
- \`)}
303
- </tbody>
304
- </table>
305
- <//>
306
- \`}
307
-
308
- \${events.length > 0 && html\`
309
- <\${Card} title="Recent Events">
310
- <div style="max-height: 200px; overflow-y: auto;">
311
- <table>
312
- <thead>
313
- <tr>
314
- <th style="width: 80px;">Time</th>
315
- <th style="width: 160px;">Type</th>
316
- <th>Source</th>
317
- <th>Details</th>
318
- </tr>
319
- </thead>
320
- <tbody>
321
- \${events.slice(0, 15).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
322
- </tbody>
323
- </table>
324
- </div>
325
- <//>
326
- \`}
327
- </div>
328
- \`
329
- }
330
-
331
- // ================================================================
332
- // EVALS PAGE
333
- // ================================================================
334
-
335
- function EvalsPage() {
336
- const [selectedAgent, setSelectedAgent] = useState('')
337
- const { leaderboard, trajectory, loading } = useEvalData(selectedAgent)
338
-
339
- useEffect(() => {
340
- if (!selectedAgent && leaderboard.length > 0) {
341
- setSelectedAgent(leaderboard[0].agent)
342
- }
343
- }, [leaderboard, selectedAgent])
344
-
345
- const metricKeys = leaderboard.length > 0
346
- ? [...new Set(leaderboard.flatMap(a => Object.keys(a.metrics || {})))]
347
- : []
348
-
349
- return html\`
350
- <div>
351
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
352
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Evals</h2>
353
- \${leaderboard.length > 0 && html\`
354
- <select class="agent-select" value=\${selectedAgent} onChange=\${e => setSelectedAgent(e.target.value)}>
355
- \${leaderboard.map(a => html\`<option key=\${a.agent} value=\${a.agent}>\${a.agent}</option>\`)}
356
- </select>
357
- \`}
358
- </div>
359
-
360
- \${loading ? html\`<div class="loading">Loading eval data</div>\` : leaderboard.length === 0 ? html\`
361
- <div class="empty-state" style="padding: 3rem;">
362
- <div style="font-size: 1.25rem; color: var(--dim); margin-bottom: 0.5rem;">No Eval Data</div>
363
- <div style="color: var(--dim); font-size: 0.85rem;">
364
- Run evals with <span style="color: var(--accent); font-family: monospace;">jfl eval run</span> to see results here.
365
- </div>
366
- </div>
367
- \` : html\`
368
- <\${ChartCard} title=\${'Composite Trajectory — ' + selectedAgent} subtitle="Score over time">
369
- <\${EvalChart} data=\${trajectory} width=\${800} height=\${240} label="composite score over time" />
370
- <//>
371
-
372
- \${metricKeys.length > 0 && html\`
373
- <div class="grid-3" style="margin-bottom: 1rem;">
374
- \${metricKeys.map(key => {
375
- const agent = leaderboard.find(a => a.agent === selectedAgent)
376
- const val = agent?.metrics?.[key]
377
- return html\`
378
- <\${MetricCard} key=\${key} value=\${val != null ? val.toFixed(3) : '—'} label=\${key} color="var(--accent)" />
379
- \`
380
- })}
381
- </div>
382
- \`}
383
-
384
- <\${Card} title="Leaderboard">
385
- <table>
386
- <thead>
387
- <tr>
388
- <th style="width: 40px;">#</th>
389
- <th>Agent</th>
390
- <th style="width: 100px;">Composite</th>
391
- <th style="width: 80px;">Trend</th>
392
- <th style="width: 80px;">Delta</th>
393
- <th style="width: 120px;">Model</th>
394
- <th style="width: 100px;">Last Run</th>
395
- </tr>
396
- </thead>
397
- <tbody>
398
- \${leaderboard.map((a, i) => html\`
399
- <tr key=\${a.agent} style=\${a.agent === selectedAgent ? 'background: oklch(0.588 0.158 241.966 / 0.06);' : ''} onClick=\${() => setSelectedAgent(a.agent)}>
400
- <td style=\${'font-weight: 700; cursor: pointer; color:' + (i === 0 ? 'var(--accent)' : 'var(--muted-foreground)')}>\${i + 1}</td>
401
- <td style="font-weight: 600; cursor: pointer;">\${a.agent}</td>
402
- <td style="font-family: monospace; color: var(--accent);">\${a.composite != null ? a.composite.toFixed(4) : '—'}</td>
403
- <td><\${Sparkline} data=\${a.trajectory} width=\${60} height=\${20} /></td>
404
- <td>\${a.delta != null ? html\`<\${DeltaBadge} delta=\${a.delta} />\` : '—'}</td>
405
- <td style="font-size: 0.75rem; color: var(--muted-foreground);">\${a.model_version || '—'}</td>
406
- <td style="font-size: 0.75rem; color: var(--dim);">\${a.lastTs ? new Date(a.lastTs).toLocaleDateString() : '—'}</td>
407
- </tr>
408
- \`)}
409
- </tbody>
410
- </table>
411
- <//>
412
- \`}
413
- </div>
414
- \`
415
- }
416
-
417
- // ================================================================
418
- // SCOPE PAGE (GTM mode only)
419
- // ================================================================
420
-
421
- function ScopePage({ config }) {
422
- const services = (config?.registered_services || [])
423
- const selfName = config?.name || 'This Workspace'
424
-
425
- if (services.length === 0) {
426
- return html\`
427
- <div>
428
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Scope</h2>
429
- <div class="empty-state" style="padding: 3rem;">
430
- <div style="color: var(--dim);">No registered services.</div>
431
- <div style="color: var(--dim); font-size: 0.8rem; margin-top: 0.5rem;">
432
- Register services with <span style="color: var(--accent); font-family: monospace;">jfl services register</span>
433
- </div>
434
- </div>
435
- </div>
436
- \`
437
- }
438
-
439
- const nodeW = 160
440
- const nodeH = 50
441
- const svgW = Math.max(500, (services.length + 1) * (nodeW + 40))
442
- const svgH = 280
443
- const topX = svgW / 2 - nodeW / 2
444
- const topY = 20
445
- const childY = 160
446
- const childStartX = (svgW - services.length * (nodeW + 30) + 30) / 2
447
-
448
- return html\`
449
- <div>
450
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Scope</h2>
451
- <\${Card}>
452
- <div class="scope-graph" style="overflow-x: auto;">
453
- <svg width=\${svgW} height=\${svgH} viewBox=\${'0 0 ' + svgW + ' ' + svgH}>
454
- <rect class="scope-node scope-node-self" x=\${topX} y=\${topY} width=\${nodeW} height=\${nodeH} />
455
- <text class="scope-label" x=\${topX + nodeW / 2} y=\${topY + 22} fill="var(--accent)">\${selfName}</text>
456
- <text x=\${topX + nodeW / 2} y=\${topY + 38} text-anchor="middle" fill="var(--dim)" font-size="10">\${config?.type || 'gtm'}</text>
457
-
458
- \${services.map((svc, i) => {
459
- const cx = childStartX + i * (nodeW + 30)
460
- const cy = childY
461
- const scope = svc.context_scope || {}
462
- const produces = scope.produces || []
463
- const consumes = scope.consumes || []
464
- const denied = scope.denied || []
465
-
466
- const lineX1 = topX + nodeW / 2
467
- const lineY1 = topY + nodeH
468
- const lineX2 = cx + nodeW / 2
469
- const lineY2 = cy
470
-
471
- const badgeY = cy + nodeH + 10
472
- const badges = []
473
- if (produces.length > 0) badges.push({ label: 'produces', cls: 'scope-badge-produces', items: produces })
474
- if (consumes.length > 0) badges.push({ label: 'consumes', cls: 'scope-badge-consumes', items: consumes })
475
- if (denied.length > 0) badges.push({ label: 'denied', cls: 'scope-badge-denied', items: denied })
476
-
477
- return html\`
478
- <g key=\${svc.name}>
479
- <line class="scope-line" x1=\${lineX1} y1=\${lineY1} x2=\${lineX2} y2=\${lineY2} />
480
- <rect class="scope-node" x=\${cx} y=\${cy} width=\${nodeW} height=\${nodeH} />
481
- <text class="scope-label" x=\${cx + nodeW / 2} y=\${cy + 22}>\${svc.name}</text>
482
- <text x=\${cx + nodeW / 2} y=\${cy + 38} text-anchor="middle" fill="var(--dim)" font-size="10">\${svc.type || 'service'}</text>
483
- \${badges.map((b, bi) => html\`
484
- <g key=\${b.label}>
485
- <rect class=\${b.cls} x=\${cx + bi * 55 + 2} y=\${badgeY} width=\${50} height=\${18} rx="4" stroke-width="1" />
486
- <text class="scope-badge-text" x=\${cx + bi * 55 + 27} y=\${badgeY + 13} fill="var(--foreground)">\${b.label}</text>
487
- </g>
488
- \`)}
489
- </g>
490
- \`
491
- })}
492
- </svg>
493
- </div>
494
- <//>
495
-
496
- <\${Card} title="Service Details">
497
- <table>
498
- <thead>
499
- <tr>
500
- <th>Service</th>
501
- <th>Type</th>
502
- <th>Status</th>
503
- <th>Produces</th>
504
- <th>Consumes</th>
505
- <th>Denied</th>
506
- </tr>
507
- </thead>
508
- <tbody>
509
- \${services.map(svc => {
510
- const scope = svc.context_scope || {}
511
- return html\`
512
- <tr key=\${svc.name}>
513
- <td style="font-weight: 600;">\${svc.name}</td>
514
- <td style="font-size: 0.8rem; color: var(--muted-foreground);">\${svc.type || '—'}</td>
515
- <td><\${StatusBadge} status=\${svc.status || 'unknown'} /></td>
516
- <td style="font-size: 0.75rem; color: var(--success);">\${(scope.produces || []).join(', ') || '—'}</td>
517
- <td style="font-size: 0.75rem; color: var(--info);">\${(scope.consumes || []).join(', ') || '—'}</td>
518
- <td style="font-size: 0.75rem; color: var(--error);">\${(scope.denied || []).join(', ') || '—'}</td>
519
- </tr>
520
- \`
521
- })}
522
- </tbody>
523
- </table>
524
- <//>
525
- </div>
526
- \`
527
- }
528
-
529
- // ================================================================
530
- // SERVICE OVERVIEW (service mode)
531
- // ================================================================
532
-
533
- function ServiceOverviewPage({ config }) {
534
- const { journal, loading: journalLoading } = useContextData()
535
- const { events, connected } = useEventStream()
536
- const { leaderboard } = useEvalData()
537
-
538
- const serviceName = config?.name || 'Service'
539
- const serviceType = config?.type || 'service'
540
- const parentName = config?.gtm_parent ? config.gtm_parent.split('/').pop() : null
541
-
542
- const myEval = leaderboard.find(a => a.agent.toLowerCase().includes(serviceName.toLowerCase()))
543
-
544
- const myEvents = events.filter(e =>
545
- (e.source || '').toLowerCase().includes(serviceName.toLowerCase())
546
- )
547
-
548
- return html\`
549
- <div>
550
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
551
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">My Status</h2>
552
- <\${LiveIndicator} connected=\${connected} />
553
- </div>
554
-
555
- <div class="grid-3" style="margin-bottom: 1.5rem;">
556
- <\${MetricCard} value=\${serviceName} label=\${serviceType} description=\${parentName ? 'Parent: ' + parentName : ''} />
557
- <div class="metric-card">
558
- <div class="metric-label" style="margin-bottom: 0.5rem;">My Eval</div>
559
- \${myEval ? html\`
560
- <div style="display: flex; align-items: center; gap: 0.75rem;">
561
- <span class="metric-value" style="font-size: 1.5rem; color: var(--accent);">\${myEval.composite != null ? myEval.composite.toFixed(4) : '—'}</span>
562
- \${myEval.delta != null && html\`<\${DeltaBadge} delta=\${myEval.delta} />\`}
563
- </div>
564
- <div style="margin-top: 0.375rem;">
565
- <\${Sparkline} data=\${myEval.trajectory} width=\${100} height=\${24} />
566
- </div>
567
- \` : html\`<div style="color: var(--dim); font-size: 0.85rem;">No eval data</div>\`}
568
- </div>
569
- <\${MetricCard} value=\${myEvents.length} label="Events" description="from this service" />
570
- </div>
571
-
572
- \${myEvents.length > 0 && html\`
573
- <\${Card} title="My Events">
574
- <div style="max-height: 250px; overflow-y: auto;">
575
- <table>
576
- <thead>
577
- <tr>
578
- <th style="width: 80px;">Time</th>
579
- <th style="width: 160px;">Type</th>
580
- <th>Source</th>
581
- <th>Details</th>
582
- </tr>
583
- </thead>
584
- <tbody>
585
- \${myEvents.slice(0, 30).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
586
- </tbody>
587
- </table>
588
- </div>
589
- <//>
590
- \`}
591
-
592
- <\${Card} title="Scoped Journal">
593
- \${journalLoading ? html\`<div class="loading">Loading</div>\` :
594
- journal.length === 0 ? html\`
595
- <div style="color: var(--dim); font-size: 0.8rem; padding: 0.5rem 0;">No journal entries</div>
596
- \` : html\`
597
- <div style="max-height: 300px; overflow-y: auto;">
598
- \${journal.slice(0, 20).map(entry => html\`
599
- <\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} />
600
- \`)}
601
- </div>
602
- \`
603
- }
604
- <//>
605
- </div>
606
- \`
607
- }
608
-
609
- // ================================================================
610
- // COMMAND CENTER (Overview)
611
- // ================================================================
612
-
613
- function OverviewPage() {
614
- const { events, connected } = useEventStream()
615
- const { journal, knowledge, loading } = useContextData()
616
- const { leaderboard } = useEvalData()
617
- const { digest: telemetryDigest } = useTelemetryDigest(168)
618
- const [services, setServices] = useState({})
619
- const [projects, setProjects] = useState([])
620
- const [searchQuery, setSearchQuery] = useState('')
621
- const [searchResults, setSearchResults] = useState(null)
622
- const [searching, setSearching] = useState(false)
623
-
624
- useEffect(() => {
625
- apiFetch('/api/services').then(setServices).catch(() => {})
626
- apiFetch('/api/projects').then(setProjects).catch(() => {})
627
- }, [])
628
-
629
- function doSearch() {
630
- if (!searchQuery.trim()) return
631
- setSearching(true)
632
- apiFetch('/api/context/search', {
633
- method: 'POST',
634
- body: JSON.stringify({ query: searchQuery, maxItems: 15 })
635
- })
636
- .then(data => {
637
- const items = (data.items || []).map(item => ({
638
- type: item.type || item.source || 'unknown',
639
- title: item.title || 'Untitled',
640
- content: item.content || '',
641
- summary: item.summary || '',
642
- relevance_label: item.relevance > 0.7 ? 'high' : item.relevance > 0.4 ? 'medium' : 'low',
643
- timestamp: item.timestamp
644
- }))
645
- setSearchResults(items)
646
- })
647
- .catch(() => setSearchResults([]))
648
- .finally(() => setSearching(false))
649
- }
650
-
651
- const serviceEntries = Object.entries(services || {})
652
- const okProjects = (projects || []).filter(p => p.status === 'OK').length
653
- const totalProjects = (projects || []).length
654
- const decisionCount = journal.filter(e => e.type === 'decision').length
655
-
656
- const topAgent = leaderboard[0]
657
- const evalCount = leaderboard.length
658
-
659
- const activeAgents = extractAgents(events).filter(a => a.status === 'active')
660
-
661
- return html\`
662
- <div>
663
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
664
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Command Center</h2>
665
- <\${LiveIndicator} connected=\${connected} />
666
- </div>
667
-
668
- <!-- Stats row -->
669
- <div class="grid-4" style="margin-bottom: 1rem;">
670
- <\${MetricCard} value=\${okProjects + '/' + totalProjects} label="Projects" color="var(--success)" />
671
- <\${MetricCard} value=\${journal.length} label="Journal Entries" />
672
- <\${MetricCard} value=\${decisionCount} label="Decisions" color="var(--accent)" />
673
- <div class="metric-card">
674
- <div class="metric-label" style="margin-bottom: 0.25rem;">Eval Velocity</div>
675
- \${topAgent ? html\`
676
- <div style="display: flex; align-items: center; gap: 0.5rem;">
677
- <span class="metric-value" style="font-size: 1.5rem; color: var(--accent);">\${topAgent.composite != null ? topAgent.composite.toFixed(3) : '—'}</span>
678
- \${topAgent.delta != null && html\`<\${DeltaBadge} delta=\${topAgent.delta} />\`}
679
- </div>
680
- <div style="margin-top: 0.25rem;">
681
- <\${Sparkline} data=\${topAgent.trajectory} width=\${80} height=\${18} />
682
- </div>
683
- <div class="metric-description">\${evalCount} agents evaluated</div>
684
- \` : html\`
685
- <div class="metric-value" style="color: var(--dim);">—</div>
686
- <div class="metric-description">No evals yet</div>
687
- \`}
688
- </div>
689
- </div>
690
-
691
- <!-- Active agents strip -->
692
- \${activeAgents.length > 0 && html\`
693
- <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; padding: 0.625rem 0.75rem; background: var(--card); border: 1px solid var(--border);">
694
- <span style="font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: 0.05em;">Active</span>
695
- \${activeAgents.map(a => html\`
696
- <span key=\${a.name} style="display: inline-flex; align-items: center; gap: 0.375rem; font-size: 0.8rem;">
697
- <span class="pulse-dot">
698
- <span class="pulse-dot-ping" style="background: var(--success);"></span>
699
- <span class="pulse-dot-core" style="background: var(--success);"></span>
700
- </span>
701
- \${a.name}
702
- </span>
703
- \`)}
704
- </div>
705
- \`}
706
-
707
- <!-- Run activity chart -->
708
- \${events.length > 0 && html\`
709
- <\${ChartCard} title="Activity" subtitle="Last 14 days">
710
- <\${RunActivityChart} events=\${events} days=\${14} />
711
- <//>
712
- \`}
713
-
714
- <div style="display: grid; grid-template-columns: 1fr 320px; gap: 1rem;">
715
-
716
- <!-- Left: Journal Feed -->
717
- <div>
718
- <\${Card} title="Recent Activity">
719
- \${loading ? html\`<div class="loading">Loading journal</div>\` :
720
- journal.length === 0 ? html\`
721
- <div class="empty-state" style="padding: 1.5rem;">
722
- <div style="color: var(--dim);">No journal entries yet</div>
723
- <div style="color: var(--dim); font-size: 0.75rem; margin-top: 0.5rem;">
724
- Journal entries appear as you work across sessions
725
- </div>
726
- </div>
727
- \` : html\`
728
- <div style="max-height: 360px; overflow-y: auto;" class="divide-y">
729
- \${journal.slice(0, 15).map((entry, i) => html\`
730
- <\${ActivityRow} key=\${entry.timestamp + entry.title} event=\${{ type: entry.type, ts: entry.timestamp, data: { message: entry.title } }} isNew=\${i < 3} />
731
- \`)}
732
- </div>
733
- \`
734
- }
735
- <//>
736
-
737
- \${telemetryDigest && telemetryDigest.commands && telemetryDigest.commands.length > 0 && html\`
738
- <\${Card} title="Command Health">
739
- <div class="divide-y">
740
- \${telemetryDigest.commands.slice(0, 5).map(c => html\`
741
- <div key=\${c.command} style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0;">
742
- <span style="font-size: 0.8rem; font-weight: 600; min-width: 90px; font-family: monospace;">\${c.command}</span>
743
- <span style="font-size: 0.7rem; color: var(--dim); min-width: 35px;">\${c.count}x</span>
744
- <div style="flex: 1;"><\${SuccessRateBar} rate=\${c.successRate} /></div>
745
- </div>
746
- \`)}
747
- </div>
748
- <//>
749
- \`}
750
-
751
- \${events.length > 0 && html\`
752
- <\${Card} title="Event Stream">
753
- <div style="max-height: 200px; overflow-y: auto;">
754
- <table>
755
- <thead>
756
- <tr>
757
- <th style="width: 80px;">Time</th>
758
- <th style="width: 160px;">Type</th>
759
- <th>Source</th>
760
- <th>Details</th>
761
- </tr>
762
- </thead>
763
- <tbody>
764
- \${events.slice(0, 20).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
765
- </tbody>
766
- </table>
767
- </div>
768
- <//>
769
- \`}
770
- </div>
771
-
772
- <!-- Right: Decisions + Services + Search -->
773
- <div>
774
- <\${Card} title="Recent Decisions">
775
- \${loading ? html\`<div class="loading">Loading</div>\` :
776
- (() => {
777
- const decisions = journal.filter(e => e.type === 'decision')
778
- return decisions.length === 0 ? html\`
779
- <div style="color: var(--dim); font-size: 0.8rem; padding: 0.5rem 0;">No decisions recorded</div>
780
- \` : html\`
781
- <div class="divide-y">
782
- \${decisions.slice(0, 5).map(d => html\`
783
- <div key=\${d.timestamp + d.title} class="decision-row">
784
- <div class="decision-title">\${d.title}</div>
785
- <div class="decision-meta">
786
- \${d.timestamp ? new Date(d.timestamp).toLocaleDateString() : ''}
787
- </div>
788
- \${d.content && html\`<div class="decision-summary">\${d.content.slice(0, 100)}\${d.content.length > 100 ? '...' : ''}</div>\`}
789
- </div>
790
- \`)}
791
- </div>
792
- \`
793
- })()
794
- }
795
- <//>
796
-
797
- \${serviceEntries.length > 0 && html\`
798
- <\${Card} title="Services">
799
- <div class="divide-y">
800
- \${serviceEntries.map(([name, svc]) => html\`
801
- <div key=\${name} style="display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0;">
802
- <div style="display: flex; align-items: center; gap: 0.5rem;">
803
- <span class=\${'agent-dot agent-dot-' + (svc.status === 'running' ? 'active' : 'idle')}></span>
804
- <span style="font-size: 0.8rem; font-weight: 600;">\${name}</span>
805
- </div>
806
- <div style="display: flex; align-items: center; gap: 0.5rem;">
807
- \${svc.port && svc.port !== '?' && html\`<span style="font-size: 0.7rem; color: var(--dim); font-family: monospace;">:\${svc.port}</span>\`}
808
- <span style="font-size: 0.65rem; padding: 0.1rem 0.375rem; border-radius: 4px; background: oklch(0.708 0 0 / 0.12); color: var(--muted-foreground);">\${svc.type || 'unknown'}</span>
809
- </div>
810
- </div>
811
- \`)}
812
- </div>
813
- <//>
814
- \`}
815
-
816
- <\${Card} title="Search">
817
- <div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
818
- <\${SearchInput}
819
- value=\${searchQuery}
820
- onInput=\${e => setSearchQuery(e.target.value)}
821
- placeholder="Search journal, knowledge, code..."
822
- loading=\${searching}
823
- />
824
- <button class="btn btn-primary" style="white-space: nowrap;" onClick=\${doSearch}>Search</button>
825
- </div>
826
- \${searchResults !== null && html\`
827
- <div style="max-height: 250px; overflow-y: auto;" class="divide-y">
828
- \${searchResults.length === 0 ? html\`
829
- <div style="color: var(--dim); font-size: 0.8rem; padding: 0.5rem 0;">No results found</div>
830
- \` : searchResults.map((r, i) => html\`
831
- <div key=\${i} class="memory-result">
832
- <div style="display: flex; align-items: center; gap: 0.5rem;">
833
- <\${TypeBadge} type=\${r.type || 'unknown'} />
834
- <span style="font-size: 0.8rem; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">\${r.title}</span>
835
- \${r.timestamp && html\`<span style="font-size: 0.65rem; color: var(--dim); white-space: nowrap;">\${new Date(r.timestamp).toLocaleDateString()}</span>\`}
836
- </div>
837
- <div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem; line-height: 1.4;">
838
- \${(r.content || r.summary || '').slice(0, 150)}\${(r.content || r.summary || '').length > 150 ? '...' : ''}
839
- </div>
840
- </div>
841
- \`)}
842
- </div>
843
- \`}
844
- <//>
845
- </div>
846
- </div>
847
- </div>
848
- \`
849
- }
850
-
851
- // ================================================================
852
- // EVENTS
853
- // ================================================================
854
-
855
- function EventsPage() {
856
- const { events, connected } = useEventStream()
857
- const [filter, setFilter] = useState('')
858
-
859
- const filtered = filter
860
- ? events.filter(e =>
861
- (e.type || '').toLowerCase().includes(filter.toLowerCase()) ||
862
- (e.source || '').toLowerCase().includes(filter.toLowerCase()) ||
863
- JSON.stringify(e.data || {}).toLowerCase().includes(filter.toLowerCase())
864
- )
865
- : events
866
-
867
- return html\`
868
- <div>
869
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
870
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Events</h2>
871
- <div style="display: flex; align-items: center; gap: 1rem;">
872
- <span style="font-size: 0.8rem; color: var(--dim);">\${filtered.length} events</span>
873
- <\${LiveIndicator} connected=\${connected} />
874
- </div>
875
- </div>
876
- <div style="margin-bottom: 1rem;">
877
- <\${SearchInput}
878
- value=\${filter}
879
- onInput=\${e => setFilter(e.target.value)}
880
- placeholder="Filter by type, source, or data..."
881
- />
882
- </div>
883
- <\${Card}>
884
- <div style="max-height: 600px; overflow-y: auto;">
885
- <table>
886
- <thead>
887
- <tr>
888
- <th style="width: 80px;">Time</th>
889
- <th style="width: 180px;">Type</th>
890
- <th style="width: 140px;">Source</th>
891
- <th>Data</th>
892
- </tr>
893
- </thead>
894
- <tbody>
895
- \${filtered.slice(0, 200).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
896
- </tbody>
897
- </table>
898
- \${filtered.length === 0 && html\`
899
- <div class="empty-state">
900
- <div style="color: var(--dim);">\${filter ? 'No matching events' : 'No events yet'}</div>
901
- </div>
902
- \`}
903
- </div>
904
- <//>
905
- </div>
906
- \`
907
- }
908
-
909
- // ================================================================
910
- // AGENTS
911
- // ================================================================
912
-
913
- function AgentDetailPanel({ agent, events }) {
914
- const agentEvents = events.filter(e => (e.source || '') === agent.name).slice(0, 50)
915
- const duration = agent.startedAt ? Math.round((Date.now() - new Date(agent.startedAt).getTime()) / 1000) : null
916
- const durationStr = duration !== null
917
- ? duration < 60 ? duration + 's'
918
- : duration < 3600 ? Math.round(duration / 60) + 'm'
919
- : Math.round(duration / 3600) + 'h'
920
- : 'N/A'
921
-
922
- return html\`
923
- <\${Card}>
924
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
925
- <div style="display: flex; align-items: center; gap: 0.75rem;">
926
- <span class=\${'agent-dot agent-dot-' + agent.status}></span>
927
- <span style="font-size: 1.125rem; font-weight: 700;">\${agent.name}</span>
928
- \${agent.model && html\`<span class="model-badge">\${agent.model}</span>\`}
929
- \${agent.runtime && agent.runtime !== 'local' && html\`<span class="runtime-badge">\${agent.runtime}</span>\`}
930
- </div>
931
- <span style="font-size: 0.8rem; color: var(--dim);">\${agent.status} — \${durationStr}</span>
932
- </div>
933
- \${agent.role && html\`<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.75rem;">\${agent.role}</div>\`}
934
- \${agent.currentTask && html\`<div class="active-agent-task">\${agent.currentTask}</div>\`}
935
-
936
- <div class="grid-3" style="margin-bottom: 1rem;">
937
- <\${MetricCard} value=\${agent.eventCount} label="Total Events" />
938
- <\${MetricCard} value=\${durationStr} label="Running Time" />
939
- <\${MetricCard} value=\${agent.lastSeen ? new Date(agent.lastSeen).toLocaleTimeString() : '—'} label="Last Seen" />
940
- </div>
941
-
942
- <div class="section-header">Event Feed</div>
943
- <div style="max-height: 300px; overflow-y: auto;" class="divide-y">
944
- \${agentEvents.length === 0 ? html\`<div style="color: var(--dim); font-size: 0.8rem; padding: 0.5rem 0;">No events</div>\` :
945
- agentEvents.map((e, i) => html\`
946
- <\${ActivityRow} key=\${i} event=\${e} />
947
- \`)
948
- }
949
- </div>
950
- <//>
951
- \`
952
- }
953
-
954
- function AgentsPage() {
955
- const { events, connected } = useEventStream()
956
- const agents = extractAgents(events)
957
- const [tab, setTab] = useState('active')
958
- const [selectedAgent, setSelectedAgent] = useState(null)
959
-
960
- const activeAgents = agents.filter(a => a.status === 'active')
961
- const idleAgents = agents.filter(a => a.status !== 'active')
962
- const peterEvents = events.filter(e => (e.type || '').startsWith('peter:'))
963
-
964
- return html\`
965
- <div>
966
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
967
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Agents</h2>
968
- <div style="display: flex; align-items: center; gap: 1rem;">
969
- <span style="font-size: 0.8rem; color: var(--dim);">\${activeAgents.length} active / \${agents.length} total</span>
970
- <\${LiveIndicator} connected=\${connected} />
971
- </div>
972
- </div>
973
-
974
- \${agents.length === 0 ? html\`
975
- <div class="empty-state" style="padding: 3rem;">
976
- <div style="font-size: 1.5rem; color: var(--dim); margin-bottom: 1rem;">No Agent Activity</div>
977
- <div style="color: var(--dim); max-width: 400px; margin: 0 auto; line-height: 1.6;">
978
- Agents appear here when they emit MAP events.
979
- Run <span style="color: var(--accent); font-family: monospace;">jfl peter</span> or
980
- <span style="color: var(--accent); font-family: monospace;">jfl ralph</span> to see them in action.
981
- </div>
982
- </div>
983
- \` : html\`
984
- <div class="tab-group" style="margin-bottom: 1.5rem;">
985
- <button class=\${'tab-btn ' + (tab === 'active' ? 'tab-btn-active' : '')} onClick=\${() => { setTab('active'); setSelectedAgent(null) }}>
986
- Active \${activeAgents.length > 0 ? '(' + activeAgents.length + ')' : ''}
987
- </button>
988
- <button class=\${'tab-btn ' + (tab === 'all' ? 'tab-btn-active' : '')} onClick=\${() => { setTab('all'); setSelectedAgent(null) }}>
989
- All (\${agents.length})
990
- </button>
991
- </div>
992
-
993
- \${tab === 'active' && html\`
994
- \${activeAgents.length === 0 ? html\`
995
- <div class="empty-state" style="padding: 2rem;">
996
- <div style="color: var(--dim);">No active agents</div>
997
- </div>
998
- \` : html\`
999
- <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
1000
- \${activeAgents.map(a => html\`
1001
- <div key=\${a.name} onClick=\${() => setSelectedAgent(selectedAgent?.name === a.name ? null : a)} style="cursor: pointer;">
1002
- <\${ActiveAgentCard} agent=\${a} />
1003
- </div>
1004
- \`)}
1005
- </div>
1006
- \`}
1007
-
1008
- \${idleAgents.length > 0 && html\`
1009
- <\${Card} title=\${'Idle / Completed (' + idleAgents.length + ')'}>
1010
- <div class="divide-y">
1011
- \${idleAgents.map(a => html\`
1012
- <div key=\${a.name} onClick=\${() => setSelectedAgent(selectedAgent?.name === a.name ? null : a)} style="cursor: pointer;">
1013
- <\${AgentCard} agent=\${a} />
1014
- </div>
1015
- \`)}
1016
- </div>
1017
- <//>
1018
- \`}
1019
- \`}
1020
-
1021
- \${tab === 'all' && html\`
1022
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
1023
- <\${Card} title=\${'Local (' + agents.filter(a => !a.runtime || a.runtime === 'local').length + ')'}>
1024
- <div class="divide-y">
1025
- \${agents.filter(a => !a.runtime || a.runtime === 'local').map(a => html\`
1026
- <div key=\${a.name} onClick=\${() => setSelectedAgent(selectedAgent?.name === a.name ? null : a)} style="cursor: pointer;">
1027
- <\${AgentCard} agent=\${a} />
1028
- </div>
1029
- \`)}
1030
- </div>
1031
- <//>
1032
- <\${Card} title=\${'Remote (' + agents.filter(a => a.runtime && a.runtime !== 'local').length + ')'}>
1033
- <div class="divide-y">
1034
- \${agents.filter(a => a.runtime && a.runtime !== 'local').map(a => html\`
1035
- <div key=\${a.name} onClick=\${() => setSelectedAgent(selectedAgent?.name === a.name ? null : a)} style="cursor: pointer;">
1036
- <\${AgentCard} agent=\${a} />
1037
- </div>
1038
- \`)}
1039
- </div>
1040
- \${agents.filter(a => a.runtime && a.runtime !== 'local').length === 0 && html\`<div style="color: var(--dim); font-size: 0.8rem; padding: 0.5rem 0;">None</div>\`}
1041
- <//>
1042
- </div>
1043
- \`}
1044
-
1045
- <!-- Agent Detail Panel (inline expand) -->
1046
- \${selectedAgent && html\`
1047
- <div style="margin-top: 1rem;">
1048
- <\${AgentDetailPanel} agent=\${selectedAgent} events=\${events} />
1049
- </div>
1050
- \`}
1051
-
1052
- \${peterEvents.length > 0 && html\`
1053
- <\${Card} title="Peter Parker Routing" className="margin-top: 1rem;">
1054
- <div style="max-height: 300px; overflow-y: auto;">
1055
- <table>
1056
- <thead>
1057
- <tr>
1058
- <th>Time</th>
1059
- <th>Action</th>
1060
- <th>Details</th>
1061
- </tr>
1062
- </thead>
1063
- <tbody>
1064
- \${peterEvents.slice(0, 30).map(e => html\`
1065
- <tr key=\${e.id} class="event-row">
1066
- <td class="event-time">\${new Date(e.ts || e.timestamp).toLocaleTimeString()}</td>
1067
- <td><\${TypeBadge} type=\${e.type} /></td>
1068
- <td class="event-payload">
1069
- \${e.data?.task || e.data?.message || e.data?.agentRole || JSON.stringify(e.data || {}).slice(0, 80)}
1070
- </td>
1071
- </tr>
1072
- \`)}
1073
- </tbody>
1074
- </table>
1075
- </div>
1076
- <//>
1077
- \`}
1078
- \`}
1079
- </div>
1080
- \`
1081
- }
1082
-
1083
- // ================================================================
1084
- // PROJECTS
1085
- // ================================================================
1086
-
1087
- function ProjectsPage() {
1088
- const [projects, setProjects] = useState(null)
1089
- const [loading, setLoading] = useState(true)
1090
- const [projectJournal, setProjectJournal] = useState({})
1091
-
1092
- useEffect(() => {
1093
- async function load() {
1094
- try {
1095
- const data = await apiFetch('/api/projects')
1096
- setProjects(data)
1097
- } catch (err) {
1098
- console.error('Failed to load projects:', err)
1099
- } finally {
1100
- setLoading(false)
1101
- }
1102
- }
1103
- load()
1104
- const interval = setInterval(load, 30000)
1105
-
1106
- apiFetch('/api/context', {
1107
- method: 'POST',
1108
- body: JSON.stringify({ maxItems: 500 })
1109
- })
1110
- .then(data => {
1111
- const entries = (data.items || []).filter(i => i.source === 'journal')
1112
- const bySession = {}
1113
- for (const e of entries) {
1114
- const session = (e.path || '').split('/').pop() || 'unknown'
1115
- const project = session.replace('.jsonl', '').replace(/^session-/, '')
1116
- if (!bySession[project]) bySession[project] = { count: 0, lastTs: '' }
1117
- bySession[project].count++
1118
- if (e.timestamp && e.timestamp > bySession[project].lastTs) {
1119
- bySession[project].lastTs = e.timestamp
1120
- }
1121
- }
1122
- setProjectJournal(bySession)
1123
- })
1124
- .catch(() => {})
1125
-
1126
- return () => clearInterval(interval)
1127
- }, [])
1128
-
1129
- if (loading) return html\`<div class="loading">Loading projects</div>\`
1130
-
1131
- if (!projects || projects.length === 0) {
1132
- return html\`
1133
- <div>
1134
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Projects</h2>
1135
- <div class="empty-state">
1136
- <div style="color: var(--dim);">No tracked projects found.</div>
1137
- </div>
1138
- </div>
1139
- \`
1140
- }
1141
-
1142
- const ok = projects.filter(p => p.status === 'OK').length
1143
- const down = projects.filter(p => p.status === 'DOWN').length
1144
- const zombie = projects.filter(p => p.status === 'ZOMBIE').length
1145
-
1146
- return html\`
1147
- <div>
1148
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Projects</h2>
1149
- <div class="grid-3" style="margin-bottom: 1rem;">
1150
- <\${MetricCard} value=\${ok} label="Healthy" color="var(--success)" />
1151
- <\${MetricCard} value=\${down} label="Down" color=\${down > 0 ? 'var(--error)' : 'var(--dim)'} />
1152
- <\${MetricCard} value=\${zombie} label="Zombie" color=\${zombie > 0 ? 'var(--warning)' : 'var(--dim)'} />
1153
- </div>
1154
- <\${Card}>
1155
- <table>
1156
- <thead>
1157
- <tr>
1158
- <th>Project</th>
1159
- <th>Port</th>
1160
- <th>Status</th>
1161
- <th>PID</th>
1162
- <th>Journal Entries</th>
1163
- <th>Last Active</th>
1164
- </tr>
1165
- </thead>
1166
- <tbody>
1167
- \${projects.map(p => {
1168
- const name = p.name || ''
1169
- const journalInfo = Object.entries(projectJournal).find(([k]) => name.toLowerCase().includes(k.toLowerCase().slice(0, 8)))
1170
- const entryCount = journalInfo ? journalInfo[1].count : '-'
1171
- const lastActive = journalInfo && journalInfo[1].lastTs
1172
- ? new Date(journalInfo[1].lastTs).toLocaleDateString()
1173
- : '-'
1174
- return html\`
1175
- <tr key=\${p.path}>
1176
- <td style="font-weight: 600;">\${p.name}</td>
1177
- <td style="font-family: monospace; font-size: 0.8rem;">\${p.port}</td>
1178
- <td><\${StatusBadge} status=\${p.status} /></td>
1179
- <td style="font-family: monospace; font-size: 0.8rem; color: var(--muted-foreground);">\${p.pid || '-'}</td>
1180
- <td style="font-size: 0.8rem; color: var(--muted-foreground);">\${entryCount}</td>
1181
- <td style="font-size: 0.8rem; color: var(--muted-foreground);">\${lastActive}</td>
1182
- </tr>
1183
- \`
1184
- })}
1185
- </tbody>
1186
- </table>
1187
- <//>
1188
- </div>
1189
- \`
1190
- }
1191
-
1192
- // ================================================================
1193
- // SESSIONS
1194
- // ================================================================
1195
-
1196
- function SessionsPage() {
1197
- const { journal, loading } = useContextData()
1198
-
1199
- const sessions = new Map()
1200
- for (const entry of journal) {
1201
- const filename = (entry.path || '').split('/').pop() || ''
1202
- const sid = filename.replace('.jsonl', '') || 'unknown'
1203
- if (!sessions.has(sid)) {
1204
- sessions.set(sid, {
1205
- id: sid,
1206
- entries: [],
1207
- types: new Set(),
1208
- firstTs: entry.timestamp,
1209
- lastTs: entry.timestamp,
1210
- })
1211
- }
1212
- const s = sessions.get(sid)
1213
- s.entries.push(entry)
1214
- s.types.add(entry.type)
1215
- if (entry.timestamp) {
1216
- if (!s.firstTs || entry.timestamp < s.firstTs) s.firstTs = entry.timestamp
1217
- if (!s.lastTs || entry.timestamp > s.lastTs) s.lastTs = entry.timestamp
1218
- }
1219
- }
1220
-
1221
- const sessionList = [...sessions.values()]
1222
- .sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''))
1223
-
1224
- return html\`
1225
- <div>
1226
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Sessions</h2>
1227
- \${loading ? html\`<div class="loading">Loading sessions</div>\` :
1228
- sessionList.length === 0 ? html\`
1229
- <div class="empty-state" style="padding: 3rem;">
1230
- <div style="color: var(--dim);">No sessions found in journal.</div>
1231
- </div>
1232
- \` : html\`
1233
- <div style="margin-bottom: 1rem; font-size: 0.8rem; color: var(--dim);">
1234
- \${sessionList.length} sessions from journal entries
1235
- </div>
1236
- <div style="display: grid; gap: 0.75rem;">
1237
- \${sessionList.map(s => html\`
1238
- <\${Card} key=\${s.id}>
1239
- <div style="display: flex; justify-content: space-between; align-items: flex-start;">
1240
- <div>
1241
- <div style="font-weight: 600; font-family: monospace; font-size: 0.85rem;">\${s.id}</div>
1242
- <div style="font-size: 0.75rem; color: var(--dim); margin-top: 0.25rem;">
1243
- \${s.firstTs ? new Date(s.firstTs).toLocaleString() : ''}
1244
- \${s.lastTs && s.lastTs !== s.firstTs ? ' — ' + new Date(s.lastTs).toLocaleTimeString() : ''}
1245
- </div>
1246
- </div>
1247
- <span style="font-size: 0.8rem; font-weight: 600; color: var(--muted-foreground);">
1248
- \${s.entries.length} entries
1249
- </span>
1250
- </div>
1251
- <div style="display: flex; gap: 0.375rem; margin-top: 0.5rem; flex-wrap: wrap;">
1252
- \${[...s.types].map(t => html\`
1253
- <\${TypeBadge} key=\${t} type=\${t} />
1254
- \`)}
1255
- </div>
1256
- <div style="margin-top: 0.625rem; border-top: 1px solid var(--border); padding-top: 0.5rem;" class="divide-y">
1257
- \${s.entries.slice(0, 3).map(entry => html\`
1258
- <div key=\${entry.timestamp + entry.title} style="display: flex; gap: 0.5rem; align-items: baseline; padding: 0.2rem 0; font-size: 0.75rem;">
1259
- <span style="color: var(--dim); white-space: nowrap;">\${entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
1260
- <span style="color: var(--muted-foreground);">\${entry.title}</span>
1261
- </div>
1262
- \`)}
1263
- </div>
1264
- <//>
1265
- \`)}
1266
- </div>
1267
- \`
1268
- }
1269
- </div>
1270
- \`
1271
- }
1272
-
1273
- // ================================================================
1274
- // COSTS PAGE
1275
- // ================================================================
1276
-
1277
- function CostsPage() {
1278
- const [period, setPeriod] = useState(168)
1279
- const { digest, loading } = useTelemetryDigest(period)
1280
-
1281
- const totalTokens = digest ? digest.costs.reduce((sum, c) => sum + c.totalTokens, 0) : 0
1282
- const totalCalls = digest ? digest.costs.reduce((sum, c) => sum + c.calls, 0) : 0
1283
-
1284
- return html\`
1285
- <div>
1286
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
1287
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Costs</h2>
1288
- <div style="display: flex; gap: 0.375rem;">
1289
- \${[{v: 24, l: '24h'}, {v: 168, l: '7d'}, {v: 720, l: '30d'}].map(p => html\`
1290
- <button key=\${p.v} class=\${'btn ' + (period === p.v ? 'btn-ghost active' : 'btn-ghost')}
1291
- style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
1292
- onClick=\${() => setPeriod(p.v)}>\${p.l}</button>
1293
- \`)}
1294
- </div>
1295
- </div>
1296
-
1297
- \${loading ? html\`<div class="loading">Loading telemetry</div>\` : !digest ? html\`
1298
- <div class="empty-state" style="padding: 3rem;">
1299
- <div style="font-size: 1.25rem; color: var(--dim); margin-bottom: 0.5rem;">No Telemetry Data</div>
1300
- <div style="color: var(--dim); font-size: 0.85rem;">
1301
- Run some <span style="color: var(--accent); font-family: monospace;">jfl</span> commands to generate telemetry events.
1302
- </div>
1303
- </div>
1304
- \` : html\`
1305
- <div class="grid-4" style="margin-bottom: 1.5rem;">
1306
- <\${MetricCard} value=\${'$' + digest.totalCostUsd.toFixed(4)} label="Total Spend" color="var(--accent)" />
1307
- <\${MetricCard} value=\${totalCalls} label="API Calls" />
1308
- <\${MetricCard} value=\${totalTokens.toLocaleString()} label="Total Tokens" />
1309
- <\${MetricCard} value=\${digest.sessions.started} label="Sessions" />
1310
- </div>
1311
-
1312
- \${digest.costs.length > 0 && html\`
1313
- <\${Card} title="Cost by Model">
1314
- <table>
1315
- <thead>
1316
- <tr>
1317
- <th>Model</th>
1318
- <th style="width: 70px;">Calls</th>
1319
- <th style="width: 120px;">Tokens (P/C)</th>
1320
- <th style="width: 140px;">Token Split</th>
1321
- <th style="width: 90px;">Cost</th>
1322
- <th style="width: 120px;">Utilization</th>
1323
- <th style="width: 90px;">Avg Latency</th>
1324
- </tr>
1325
- </thead>
1326
- <tbody>
1327
- \${digest.costs.map(c => {
1328
- const utilPct = totalTokens > 0 ? Math.round((c.totalTokens / totalTokens) * 100) : 0
1329
- return html\`
1330
- <tr key=\${c.model}>
1331
- <td style="font-weight: 600; font-size: 0.8rem;">\${c.model}</td>
1332
- <td style="font-family: monospace;">\${c.calls}</td>
1333
- <td style="font-family: monospace; font-size: 0.75rem;">\${c.promptTokens.toLocaleString()} / \${c.completionTokens.toLocaleString()}</td>
1334
- <td><\${CostBar} prompt=\${c.promptTokens} completion=\${c.completionTokens} /></td>
1335
- <td style="font-family: monospace; color: var(--accent);">$\${c.estimatedCostUsd.toFixed(4)}</td>
1336
- <td><\${UtilBar} value=\${utilPct} max=\${100} /></td>
1337
- <td style="font-family: monospace; font-size: 0.75rem; color: var(--muted-foreground);">\${c.avgLatencyMs > 0 ? Math.round(c.avgLatencyMs) + 'ms' : '—'}</td>
1338
- </tr>
1339
- \`
1340
- })}
1341
- </tbody>
1342
- </table>
1343
- <//>
1344
- \`}
1345
-
1346
- <div class="grid-2">
1347
- \${digest.commands.length > 0 && html\`
1348
- <\${Card} title="Command Usage">
1349
- <div style="max-height: 300px; overflow-y: auto;" class="divide-y">
1350
- \${digest.commands.slice(0, 10).map(c => html\`
1351
- <div key=\${c.command} style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0;">
1352
- <span style="font-size: 0.8rem; font-weight: 600; min-width: 100px; font-family: monospace;">\${c.command}</span>
1353
- <span style="font-size: 0.75rem; color: var(--dim); min-width: 40px;">\${c.count}x</span>
1354
- <div style="flex: 1;"><\${SuccessRateBar} rate=\${c.successRate} /></div>
1355
- </div>
1356
- \`)}
1357
- </div>
1358
- <//>
1359
- \`}
1360
-
1361
- <\${Card} title="System Health">
1362
- <\${StatRow} label="Hub Starts" value=\${digest.hubHealth.starts} />
1363
- <\${StatRow} label="Hub Crashes" value=\${digest.hubHealth.crashes} />
1364
- <\${StatRow} label="MCP Calls" value=\${digest.hubHealth.mcpCalls} />
1365
- <\${StatRow} label="Avg MCP Latency" value=\${digest.hubHealth.avgMcpLatencyMs + 'ms'} />
1366
- <\${StatRow} label="Memory Index Runs" value=\${digest.memoryHealth.indexRuns} />
1367
- <\${StatRow} label="Entries Indexed" value=\${digest.memoryHealth.entriesIndexed} />
1368
- <\${StatRow} label="Session Crashes" value=\${digest.sessions.crashed} />
1369
- <\${StatRow} label="Errors" value=\${digest.errors.total} />
1370
- <//>
1371
- </div>
1372
- \`}
1373
- </div>
1374
- \`
1375
- }
1376
-
1377
- // ================================================================
1378
- // FLOWS PAGE
1379
- // ================================================================
1380
-
1381
- function FlowsPage() {
1382
- const { flows, executions, loading } = useFlowData()
1383
- const [tab, setTab] = useState('pending')
1384
-
1385
- const pending = executions.filter(e => e.gated)
1386
- const all = executions
1387
-
1388
- async function approveExecution(flowName, triggerId) {
1389
- try {
1390
- await apiFetch('/api/flows/' + encodeURIComponent(flowName) + '/approve', {
1391
- method: 'POST',
1392
- body: JSON.stringify({ trigger_event_id: triggerId })
1393
- })
1394
- } catch (err) {
1395
- console.error('Approval failed:', err)
1396
- }
1397
- }
1398
-
1399
- return html\`
1400
- <div>
1401
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 1.5rem;">Flows</h2>
1402
- <div class="tab-group" style="margin-bottom: 1.5rem;">
1403
- <button class=\${'tab-btn ' + (tab === 'pending' ? 'tab-btn-active' : '')} onClick=\${() => setTab('pending')}>
1404
- Pending \${pending.length > 0 ? '(' + pending.length + ')' : ''}
1405
- </button>
1406
- <button class=\${'tab-btn ' + (tab === 'all' ? 'tab-btn-active' : '')} onClick=\${() => setTab('all')}>
1407
- All
1408
- </button>
1409
- <button class=\${'tab-btn ' + (tab === 'definitions' ? 'tab-btn-active' : '')} onClick=\${() => setTab('definitions')}>
1410
- Definitions
1411
- </button>
1412
- </div>
1413
-
1414
- \${loading ? html\`<div class="loading">Loading flows</div>\` : html\`
1415
- \${tab === 'pending' && html\`
1416
- \${pending.length === 0 ? html\`
1417
- <div class="empty-state" style="padding: 3rem;">
1418
- <div style="color: var(--dim);">No pending approvals</div>
1419
- </div>
1420
- \` : html\`
1421
- <div style="display: grid; gap: 0.75rem;">
1422
- \${pending.map((ex, i) => html\`
1423
- <\${Card} key=\${i}>
1424
- <div style="display: flex; justify-content: space-between; align-items: center;">
1425
- <div>
1426
- <div style="font-weight: 700;">\${ex.flow}</div>
1427
- <div style="font-size: 0.75rem; color: var(--dim); margin-top: 0.25rem;">
1428
- Trigger: \${ex.trigger_event_type} — \${new Date(ex.started_at).toLocaleString()}
1429
- </div>
1430
- <span class="badge badge-gated" style="margin-top: 0.375rem;">\${ex.gated}</span>
1431
- </div>
1432
- \${ex.gated === 'approval' && html\`
1433
- <button class="btn-approve" onClick=\${() => approveExecution(ex.flow, ex.trigger_event_id)}>Approve</button>
1434
- \`}
1435
- </div>
1436
- <//>
1437
- \`)}
1438
- </div>
1439
- \`}
1440
- \`}
1441
-
1442
- \${tab === 'all' && html\`
1443
- <\${Card}>
1444
- <div style="max-height: 500px; overflow-y: auto;">
1445
- <table>
1446
- <thead>
1447
- <tr>
1448
- <th>Flow</th>
1449
- <th>Trigger</th>
1450
- <th>Status</th>
1451
- <th>Actions</th>
1452
- <th>Time</th>
1453
- </tr>
1454
- </thead>
1455
- <tbody>
1456
- \${all.length === 0 ? html\`<tr><td colspan="5" style="text-align: center; color: var(--dim);">No executions</td></tr>\` :
1457
- all.slice().reverse().map((ex, i) => {
1458
- const status = ex.gated ? 'gated' : ex.actions_failed > 0 ? 'error' : 'completed'
1459
- const badgeCls = 'badge badge-' + status
1460
- return html\`
1461
- <tr key=\${i}>
1462
- <td style="font-weight: 600;">\${ex.flow}</td>
1463
- <td style="font-size: 0.75rem; color: var(--muted-foreground);">\${ex.trigger_event_type}</td>
1464
- <td><span class=\${badgeCls}>\${ex.gated || status}</span></td>
1465
- <td style="font-family: monospace; font-size: 0.8rem;">\${ex.actions_executed}\${ex.actions_failed > 0 ? ' / ' + ex.actions_failed + ' failed' : ''}</td>
1466
- <td style="font-size: 0.75rem; color: var(--dim);">\${ex.started_at ? new Date(ex.started_at).toLocaleString() : '—'}</td>
1467
- </tr>
1468
- \`
1469
- })
1470
- }
1471
- </tbody>
1472
- </table>
1473
- </div>
1474
- <//>
1475
- \`}
1476
-
1477
- \${tab === 'definitions' && html\`
1478
- \${flows.length === 0 ? html\`
1479
- <div class="empty-state" style="padding: 3rem;">
1480
- <div style="color: var(--dim);">No flows defined</div>
1481
- <div style="font-size: 0.8rem; color: var(--dim); margin-top: 0.5rem;">
1482
- Create flows in <span style="color: var(--accent); font-family: monospace;">.jfl/flows.yaml</span>
1483
- </div>
1484
- </div>
1485
- \` : html\`
1486
- <div style="display: grid; gap: 0.75rem;">
1487
- \${flows.map(f => html\`
1488
- <\${Card} key=\${f.name}>
1489
- <div style="display: flex; justify-content: space-between; align-items: flex-start;">
1490
- <div>
1491
- <div style="font-weight: 700;">\${f.name}</div>
1492
- \${f.description && html\`<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.25rem;">\${f.description}</div>\`}
1493
- </div>
1494
- <span class=\${'badge ' + (f.enabled !== false ? 'badge-ok' : 'badge-unknown')}>\${f.enabled !== false ? 'enabled' : 'disabled'}</span>
1495
- </div>
1496
- <div style="margin-top: 0.75rem; display: flex; gap: 1rem; font-size: 0.75rem; color: var(--dim);">
1497
- <span>Trigger: <span style="color: var(--info); font-family: monospace;">\${f.trigger?.pattern}</span></span>
1498
- <span>Actions: \${(f.actions || []).length}</span>
1499
- \${f.gate?.requires_approval && html\`<span style="color: var(--warning);">Requires approval</span>\`}
1500
- </div>
1501
- <//>
1502
- \`)}
1503
- </div>
1504
- \`}
1505
- \`}
1506
- \`}
1507
- </div>
1508
- \`
1509
- }
1510
-
1511
- // ================================================================
1512
- // JOURNAL
1513
- // ================================================================
1514
-
1515
- function JournalPage() {
1516
- const { journal, loading } = useContextData()
1517
- const [typeFilter, setTypeFilter] = useState('')
1518
- const [searchFilter, setSearchFilter] = useState('')
1519
-
1520
- const types = [...new Set(journal.map(e => e.type))].sort()
1521
-
1522
- const filtered = journal.filter(entry => {
1523
- if (typeFilter && entry.type !== typeFilter) return false
1524
- if (searchFilter) {
1525
- const q = searchFilter.toLowerCase()
1526
- return (entry.title || '').toLowerCase().includes(q) ||
1527
- (entry.content || '').toLowerCase().includes(q)
1528
- }
1529
- return true
1530
- })
1531
-
1532
- return html\`
1533
- <div>
1534
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
1535
- <h2 style="font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em;">Journal</h2>
1536
- <span style="font-size: 0.8rem; color: var(--dim);">\${filtered.length} entries</span>
1537
- </div>
1538
-
1539
- <div style="display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap;">
1540
- <\${SearchInput}
1541
- value=\${searchFilter}
1542
- onInput=\${e => setSearchFilter(e.target.value)}
1543
- placeholder="Search journal entries..."
1544
- />
1545
- <div style="display: flex; gap: 0.375rem; flex-wrap: wrap; align-items: center;">
1546
- <button
1547
- class=\${'btn ' + (!typeFilter ? 'btn-ghost active' : 'btn-ghost')}
1548
- style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
1549
- onClick=\${() => setTypeFilter('')}
1550
- >All</button>
1551
- \${types.map(t => html\`
1552
- <button
1553
- key=\${t}
1554
- class=\${'btn ' + (typeFilter === t ? 'btn-ghost active' : 'btn-ghost')}
1555
- style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
1556
- onClick=\${() => setTypeFilter(typeFilter === t ? '' : t)}
1557
- >\${t}</button>
1558
- \`)}
1559
- </div>
1560
- </div>
1561
-
1562
- \${loading ? html\`<div class="loading">Loading journal</div>\` : html\`
1563
- <\${Card}>
1564
- <div style="max-height: 600px; overflow-y: auto;" class="divide-y">
1565
- \${filtered.length === 0 ? html\`
1566
- <div class="empty-state">
1567
- <div style="color: var(--dim);">\${searchFilter || typeFilter ? 'No matching entries' : 'No journal entries'}</div>
1568
- </div>
1569
- \` : filtered.map(entry => html\`
1570
- <\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} expanded=\${true} />
1571
- \`)}
1572
- </div>
1573
- <//>
1574
- \`}
1575
- </div>
1576
- \`
1577
- }
1578
- `;
1579
- }
1580
- //# sourceMappingURL=pages.js.map