jfl 0.2.2 → 0.2.3
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.
- package/clawdbot-plugin/clawdbot.plugin.json +12 -1
- package/clawdbot-plugin/index.js +5 -5
- package/clawdbot-plugin/index.ts +5 -5
- package/dist/commands/context-hub.d.ts +4 -0
- package/dist/commands/context-hub.d.ts.map +1 -1
- package/dist/commands/context-hub.js +570 -81
- package/dist/commands/context-hub.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +42 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/peter.d.ts +15 -0
- package/dist/commands/peter.d.ts.map +1 -0
- package/dist/commands/peter.js +198 -0
- package/dist/commands/peter.js.map +1 -0
- package/dist/commands/ralph.d.ts +3 -1
- package/dist/commands/ralph.d.ts.map +1 -1
- package/dist/commands/ralph.js +40 -5
- package/dist/commands/ralph.js.map +1 -1
- package/dist/commands/session.d.ts +2 -1
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +496 -49
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +25 -6
- package/dist/commands/update.js.map +1 -1
- package/dist/dashboard/components.d.ts +7 -0
- package/dist/dashboard/components.d.ts.map +1 -0
- package/dist/dashboard/components.js +163 -0
- package/dist/dashboard/components.js.map +1 -0
- package/dist/dashboard/index.d.ts +12 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +132 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/pages.d.ts +7 -0
- package/dist/dashboard/pages.d.ts.map +1 -0
- package/dist/dashboard/pages.js +742 -0
- package/dist/dashboard/pages.js.map +1 -0
- package/dist/dashboard/styles.d.ts +7 -0
- package/dist/dashboard/styles.d.ts.map +1 -0
- package/dist/dashboard/styles.js +497 -0
- package/dist/dashboard/styles.js.map +1 -0
- package/dist/index.js +30 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/map-event-bus.d.ts +48 -0
- package/dist/lib/map-event-bus.d.ts.map +1 -0
- package/dist/lib/map-event-bus.js +326 -0
- package/dist/lib/map-event-bus.js.map +1 -0
- package/dist/lib/peter-parker-bridge.d.ts +33 -0
- package/dist/lib/peter-parker-bridge.d.ts.map +1 -0
- package/dist/lib/peter-parker-bridge.js +116 -0
- package/dist/lib/peter-parker-bridge.js.map +1 -0
- package/dist/lib/peter-parker-config.d.ts +13 -0
- package/dist/lib/peter-parker-config.d.ts.map +1 -0
- package/dist/lib/peter-parker-config.js +86 -0
- package/dist/lib/peter-parker-config.js.map +1 -0
- package/dist/lib/service-utils.d.ts.map +1 -1
- package/dist/lib/service-utils.js +33 -17
- package/dist/lib/service-utils.js.map +1 -1
- package/dist/mcp/context-hub-mcp.js +122 -22
- package/dist/mcp/context-hub-mcp.js.map +1 -1
- package/dist/types/map.d.ts +33 -0
- package/dist/types/map.d.ts.map +1 -0
- package/dist/types/map.js +39 -0
- package/dist/types/map.js.map +1 -0
- package/dist/ui/event-dashboard.d.ts +12 -0
- package/dist/ui/event-dashboard.d.ts.map +1 -0
- package/dist/ui/event-dashboard.js +342 -0
- package/dist/ui/event-dashboard.js.map +1 -0
- package/package.json +1 -1
- package/scripts/test-map-eventbus.sh +357 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard page components
|
|
3
|
+
*
|
|
4
|
+
* @purpose Command center pages: journal-first overview, events, agents, projects, sessions, journal
|
|
5
|
+
*/
|
|
6
|
+
export function getPagesJS() {
|
|
7
|
+
return `
|
|
8
|
+
// Shared SSE hook — one connection, all pages can use it
|
|
9
|
+
function useEventStream() {
|
|
10
|
+
const [events, setEvents] = useState([])
|
|
11
|
+
const [connected, setConnected] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
apiFetch('/api/events?limit=200')
|
|
15
|
+
.then(data => setEvents(data.events || []))
|
|
16
|
+
.catch(() => {})
|
|
17
|
+
|
|
18
|
+
const token = localStorage.getItem('jfl-token')
|
|
19
|
+
const url = new URL('/api/events/stream', window.location.origin)
|
|
20
|
+
url.searchParams.set('patterns', '*')
|
|
21
|
+
if (token) url.searchParams.set('token', token)
|
|
22
|
+
const es = new EventSource(url.toString())
|
|
23
|
+
|
|
24
|
+
es.onopen = () => setConnected(true)
|
|
25
|
+
es.onerror = () => setConnected(false)
|
|
26
|
+
|
|
27
|
+
function handleEvent(e) {
|
|
28
|
+
try {
|
|
29
|
+
const event = JSON.parse(e.data)
|
|
30
|
+
setEvents(prev => [event, ...prev].slice(0, 500))
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
es.onmessage = handleEvent
|
|
35
|
+
const types = [
|
|
36
|
+
'peter:started', 'peter:task-selected', 'peter:task-completed', 'peter:all-complete',
|
|
37
|
+
'task:started', 'task:completed', 'task:failed',
|
|
38
|
+
'session:started', 'session:ended',
|
|
39
|
+
'service:healthy', 'service:unhealthy',
|
|
40
|
+
'journal:entry', 'decision:made',
|
|
41
|
+
'build:completed', 'deploy:completed',
|
|
42
|
+
'openclaw:tag', 'custom'
|
|
43
|
+
]
|
|
44
|
+
types.forEach(t => es.addEventListener(t, handleEvent))
|
|
45
|
+
|
|
46
|
+
return () => es.close()
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
return { events, connected }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fetch journal + knowledge from Context API
|
|
53
|
+
function useContextData() {
|
|
54
|
+
const [journal, setJournal] = useState([])
|
|
55
|
+
const [knowledge, setKnowledge] = useState([])
|
|
56
|
+
const [loading, setLoading] = useState(true)
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
apiFetch('/api/context', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
body: JSON.stringify({ maxItems: 100 })
|
|
62
|
+
})
|
|
63
|
+
.then(data => {
|
|
64
|
+
const items = data.items || []
|
|
65
|
+
setJournal(items.filter(i => i.source === 'journal'))
|
|
66
|
+
setKnowledge(items.filter(i => i.source === 'knowledge'))
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {})
|
|
69
|
+
.finally(() => setLoading(false))
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
return { journal, knowledge, loading }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extract agents from events
|
|
76
|
+
function extractAgents(events) {
|
|
77
|
+
const agents = new Map()
|
|
78
|
+
for (const e of events) {
|
|
79
|
+
const src = e.source || ''
|
|
80
|
+
if (!src) continue
|
|
81
|
+
|
|
82
|
+
if (!agents.has(src)) {
|
|
83
|
+
agents.set(src, {
|
|
84
|
+
name: src,
|
|
85
|
+
model: e.data?.model || e.data?.modelTier || null,
|
|
86
|
+
role: e.data?.role || e.data?.agentRole || null,
|
|
87
|
+
runtime: e.data?.runtime || null,
|
|
88
|
+
status: 'idle',
|
|
89
|
+
lastEvent: e.type,
|
|
90
|
+
lastSeen: e.ts || e.timestamp,
|
|
91
|
+
eventCount: 0,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const agent = agents.get(src)
|
|
96
|
+
agent.eventCount++
|
|
97
|
+
agent.lastEvent = e.type
|
|
98
|
+
agent.lastSeen = e.ts || e.timestamp || agent.lastSeen
|
|
99
|
+
|
|
100
|
+
if (e.data?.model) agent.model = e.data.model
|
|
101
|
+
if (e.data?.modelTier) agent.model = e.data.modelTier
|
|
102
|
+
if (e.data?.role) agent.role = e.data.role
|
|
103
|
+
if (e.data?.agentRole) agent.role = e.data.agentRole
|
|
104
|
+
if (e.data?.runtime) agent.runtime = e.data.runtime
|
|
105
|
+
|
|
106
|
+
if (e.type?.includes('started') || e.type?.includes('start')) {
|
|
107
|
+
agent.status = 'active'
|
|
108
|
+
} else if (e.type?.includes('completed') || e.type?.includes('complete') || e.type?.includes('ended')) {
|
|
109
|
+
agent.status = 'idle'
|
|
110
|
+
} else if (e.type?.includes('failed')) {
|
|
111
|
+
agent.status = 'error'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...agents.values()].sort((a, b) => {
|
|
115
|
+
if (a.status === 'active' && b.status !== 'active') return -1
|
|
116
|
+
if (b.status === 'active' && a.status !== 'active') return 1
|
|
117
|
+
return (b.eventCount || 0) - (a.eventCount || 0)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ================================================================
|
|
122
|
+
// COMMAND CENTER (Overview) — Journal-first
|
|
123
|
+
// ================================================================
|
|
124
|
+
|
|
125
|
+
function OverviewPage() {
|
|
126
|
+
const { events, connected } = useEventStream()
|
|
127
|
+
const { journal, knowledge, loading } = useContextData()
|
|
128
|
+
const [services, setServices] = useState({})
|
|
129
|
+
const [projects, setProjects] = useState([])
|
|
130
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
131
|
+
const [searchResults, setSearchResults] = useState(null)
|
|
132
|
+
const [searching, setSearching] = useState(false)
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
apiFetch('/api/services').then(setServices).catch(() => {})
|
|
136
|
+
apiFetch('/api/projects').then(setProjects).catch(() => {})
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
function doSearch() {
|
|
140
|
+
if (!searchQuery.trim()) return
|
|
141
|
+
setSearching(true)
|
|
142
|
+
apiFetch('/api/context/search', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: JSON.stringify({ query: searchQuery, maxItems: 15 })
|
|
145
|
+
})
|
|
146
|
+
.then(data => {
|
|
147
|
+
const items = (data.items || []).map(item => ({
|
|
148
|
+
type: item.type || item.source || 'unknown',
|
|
149
|
+
title: item.title || 'Untitled',
|
|
150
|
+
content: item.content || '',
|
|
151
|
+
summary: item.summary || '',
|
|
152
|
+
relevance_label: item.relevance > 0.7 ? 'high' : item.relevance > 0.4 ? 'medium' : 'low',
|
|
153
|
+
timestamp: item.timestamp
|
|
154
|
+
}))
|
|
155
|
+
setSearchResults(items)
|
|
156
|
+
})
|
|
157
|
+
.catch(() => setSearchResults([]))
|
|
158
|
+
.finally(() => setSearching(false))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const serviceEntries = Object.entries(services || {})
|
|
162
|
+
const okProjects = (projects || []).filter(p => p.status === 'OK').length
|
|
163
|
+
const totalProjects = (projects || []).length
|
|
164
|
+
const decisionCount = journal.filter(e => e.type === 'decision').length
|
|
165
|
+
|
|
166
|
+
return html\`
|
|
167
|
+
<div>
|
|
168
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
169
|
+
<h2 class="page-title" style="margin-bottom: 0;">Command Center</h2>
|
|
170
|
+
<\${LiveIndicator} connected=\${connected} />
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- Stats row -->
|
|
174
|
+
<div class="grid-4" style="margin-bottom: 1rem;">
|
|
175
|
+
<\${Card} title="Projects">
|
|
176
|
+
<div class="card-value" style="color: var(--success);">\${okProjects}<span style="font-size: 1rem; color: var(--text-dim);">/\${totalProjects}</span></div>
|
|
177
|
+
<//>
|
|
178
|
+
<\${Card} title="Journal Entries">
|
|
179
|
+
<div class="card-value">\${journal.length}</div>
|
|
180
|
+
<//>
|
|
181
|
+
<\${Card} title="Decisions">
|
|
182
|
+
<div class="card-value" style="color: var(--accent);">\${decisionCount}</div>
|
|
183
|
+
<//>
|
|
184
|
+
<\${Card} title="Services">
|
|
185
|
+
<div class="card-value">\${serviceEntries.length}</div>
|
|
186
|
+
<//>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div style="display: grid; grid-template-columns: 1fr 320px; gap: 1rem;">
|
|
190
|
+
|
|
191
|
+
<!-- Left: Journal Feed -->
|
|
192
|
+
<div>
|
|
193
|
+
<\${Card} title="Recent Activity">
|
|
194
|
+
\${loading ? html\`<div class="loading">Loading journal</div>\` :
|
|
195
|
+
journal.length === 0 ? html\`
|
|
196
|
+
<div class="empty-state" style="padding: 1.5rem;">
|
|
197
|
+
<div style="color: var(--text-dim);">No journal entries yet</div>
|
|
198
|
+
<div style="color: var(--text-dim); font-size: 0.75rem; margin-top: 0.5rem;">
|
|
199
|
+
Journal entries appear as you work across sessions
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
\` : html\`
|
|
203
|
+
<div style="max-height: 360px; overflow-y: auto;">
|
|
204
|
+
\${journal.slice(0, 15).map(entry => html\`
|
|
205
|
+
<\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} />
|
|
206
|
+
\`)}
|
|
207
|
+
</div>
|
|
208
|
+
\`
|
|
209
|
+
}
|
|
210
|
+
<//>
|
|
211
|
+
|
|
212
|
+
<!-- Event stream (secondary, below journal) -->
|
|
213
|
+
\${events.length > 0 && html\`
|
|
214
|
+
<\${Card} title="Event Stream">
|
|
215
|
+
<div style="max-height: 200px; overflow-y: auto;">
|
|
216
|
+
<table>
|
|
217
|
+
<thead>
|
|
218
|
+
<tr>
|
|
219
|
+
<th style="width: 80px;">Time</th>
|
|
220
|
+
<th style="width: 160px;">Type</th>
|
|
221
|
+
<th>Source</th>
|
|
222
|
+
<th>Details</th>
|
|
223
|
+
</tr>
|
|
224
|
+
</thead>
|
|
225
|
+
<tbody>
|
|
226
|
+
\${events.slice(0, 20).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
|
|
227
|
+
</tbody>
|
|
228
|
+
</table>
|
|
229
|
+
</div>
|
|
230
|
+
<//>
|
|
231
|
+
\`}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- Right: Decisions + Services + Memory Search -->
|
|
235
|
+
<div>
|
|
236
|
+
<\${Card} title="Recent Decisions">
|
|
237
|
+
\${loading ? html\`<div class="loading">Loading</div>\` :
|
|
238
|
+
(() => {
|
|
239
|
+
const decisions = journal.filter(e => e.type === 'decision')
|
|
240
|
+
return decisions.length === 0 ? html\`
|
|
241
|
+
<div style="color: var(--text-dim); font-size: 0.8rem; padding: 0.5rem 0;">No decisions recorded</div>
|
|
242
|
+
\` : html\`
|
|
243
|
+
<div>
|
|
244
|
+
\${decisions.slice(0, 5).map(d => html\`
|
|
245
|
+
<div key=\${d.timestamp + d.title} class="decision-row">
|
|
246
|
+
<div class="decision-title">\${d.title}</div>
|
|
247
|
+
<div class="decision-meta">
|
|
248
|
+
\${d.timestamp ? new Date(d.timestamp).toLocaleDateString() : ''}
|
|
249
|
+
</div>
|
|
250
|
+
\${d.content && html\`<div class="decision-summary">\${d.content.slice(0, 100)}\${d.content.length > 100 ? '...' : ''}</div>\`}
|
|
251
|
+
</div>
|
|
252
|
+
\`)}
|
|
253
|
+
</div>
|
|
254
|
+
\`
|
|
255
|
+
})()
|
|
256
|
+
}
|
|
257
|
+
<//>
|
|
258
|
+
|
|
259
|
+
\${serviceEntries.length > 0 && html\`
|
|
260
|
+
<\${Card} title="Services">
|
|
261
|
+
<div style="display: grid; gap: 0.375rem;">
|
|
262
|
+
\${serviceEntries.map(([name, svc]) => html\`
|
|
263
|
+
<div key=\${name} style="display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3);">
|
|
264
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
265
|
+
<span class=\${'agent-dot agent-dot-' + (svc.status === 'running' ? 'active' : 'idle')}></span>
|
|
266
|
+
<span style="font-size: 0.8rem; font-weight: 600;">\${name}</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
269
|
+
\${svc.port && svc.port !== '?' && html\`<span style="font-size: 0.7rem; color: var(--text-dim); font-family: monospace;">:\${svc.port}</span>\`}
|
|
270
|
+
<span style="font-size: 0.65rem; padding: 0.1rem 0.375rem; border-radius: 4px; background: rgba(148,163,184,0.12); color: var(--text-soft);">\${svc.type || 'unknown'}</span>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
\`)}
|
|
274
|
+
</div>
|
|
275
|
+
<//>
|
|
276
|
+
\`}
|
|
277
|
+
|
|
278
|
+
<\${Card} title="Search">
|
|
279
|
+
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
|
|
280
|
+
<\${SearchInput}
|
|
281
|
+
value=\${searchQuery}
|
|
282
|
+
onInput=\${e => setSearchQuery(e.target.value)}
|
|
283
|
+
placeholder="Search journal, knowledge, code..."
|
|
284
|
+
loading=\${searching}
|
|
285
|
+
/>
|
|
286
|
+
<button class="btn btn-primary" style="white-space: nowrap;" onClick=\${doSearch}>Search</button>
|
|
287
|
+
</div>
|
|
288
|
+
\${searchResults !== null && html\`
|
|
289
|
+
<div style="max-height: 250px; overflow-y: auto;">
|
|
290
|
+
\${searchResults.length === 0 ? html\`
|
|
291
|
+
<div style="color: var(--text-dim); font-size: 0.8rem; padding: 0.5rem 0;">No results found</div>
|
|
292
|
+
\` : searchResults.map((r, i) => html\`
|
|
293
|
+
<div key=\${i} class="memory-result">
|
|
294
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
295
|
+
<\${TypeBadge} type=\${r.type || 'unknown'} />
|
|
296
|
+
<span style="font-size: 0.8rem; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">\${r.title}</span>
|
|
297
|
+
\${r.timestamp && html\`<span style="font-size: 0.65rem; color: var(--text-dim); white-space: nowrap;">\${new Date(r.timestamp).toLocaleDateString()}</span>\`}
|
|
298
|
+
</div>
|
|
299
|
+
<div style="font-size: 0.75rem; color: var(--text-soft); margin-top: 0.25rem; line-height: 1.4;">
|
|
300
|
+
\${(r.content || r.summary || '').slice(0, 150)}\${(r.content || r.summary || '').length > 150 ? '...' : ''}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
\`)}
|
|
304
|
+
</div>
|
|
305
|
+
\`}
|
|
306
|
+
<//>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
\`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ================================================================
|
|
314
|
+
// EVENTS (full stream with filtering)
|
|
315
|
+
// ================================================================
|
|
316
|
+
|
|
317
|
+
function EventsPage() {
|
|
318
|
+
const { events, connected } = useEventStream()
|
|
319
|
+
const [filter, setFilter] = useState('')
|
|
320
|
+
|
|
321
|
+
const filtered = filter
|
|
322
|
+
? events.filter(e =>
|
|
323
|
+
(e.type || '').toLowerCase().includes(filter.toLowerCase()) ||
|
|
324
|
+
(e.source || '').toLowerCase().includes(filter.toLowerCase()) ||
|
|
325
|
+
JSON.stringify(e.data || {}).toLowerCase().includes(filter.toLowerCase())
|
|
326
|
+
)
|
|
327
|
+
: events
|
|
328
|
+
|
|
329
|
+
return html\`
|
|
330
|
+
<div>
|
|
331
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
332
|
+
<h2 class="page-title" style="margin-bottom: 0;">Events</h2>
|
|
333
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
334
|
+
<span style="font-size: 0.8rem; color: var(--text-dim);">\${filtered.length} events</span>
|
|
335
|
+
<\${LiveIndicator} connected=\${connected} />
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
<div style="margin-bottom: 1rem;">
|
|
339
|
+
<\${SearchInput}
|
|
340
|
+
value=\${filter}
|
|
341
|
+
onInput=\${e => setFilter(e.target.value)}
|
|
342
|
+
placeholder="Filter by type, source, or data..."
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
<\${Card}>
|
|
346
|
+
<div style="max-height: 600px; overflow-y: auto;">
|
|
347
|
+
<table>
|
|
348
|
+
<thead>
|
|
349
|
+
<tr>
|
|
350
|
+
<th style="width: 80px;">Time</th>
|
|
351
|
+
<th style="width: 180px;">Type</th>
|
|
352
|
+
<th style="width: 140px;">Source</th>
|
|
353
|
+
<th>Data</th>
|
|
354
|
+
</tr>
|
|
355
|
+
</thead>
|
|
356
|
+
<tbody>
|
|
357
|
+
\${filtered.slice(0, 200).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
|
|
358
|
+
</tbody>
|
|
359
|
+
</table>
|
|
360
|
+
\${filtered.length === 0 && html\`
|
|
361
|
+
<div class="empty-state">
|
|
362
|
+
<div style="color: var(--text-dim);">\${filter ? 'No matching events' : 'No events yet'}</div>
|
|
363
|
+
</div>
|
|
364
|
+
\`}
|
|
365
|
+
</div>
|
|
366
|
+
<//>
|
|
367
|
+
</div>
|
|
368
|
+
\`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ================================================================
|
|
372
|
+
// AGENTS
|
|
373
|
+
// ================================================================
|
|
374
|
+
|
|
375
|
+
function AgentsPage() {
|
|
376
|
+
const { events, connected } = useEventStream()
|
|
377
|
+
const agents = extractAgents(events)
|
|
378
|
+
|
|
379
|
+
const localAgents = agents.filter(a => !a.runtime || a.runtime === 'local')
|
|
380
|
+
const remoteAgents = agents.filter(a => a.runtime && a.runtime !== 'local')
|
|
381
|
+
const peterEvents = events.filter(e => (e.type || '').startsWith('peter:'))
|
|
382
|
+
|
|
383
|
+
return html\`
|
|
384
|
+
<div>
|
|
385
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
386
|
+
<h2 class="page-title" style="margin-bottom: 0;">Agents</h2>
|
|
387
|
+
<\${LiveIndicator} connected=\${connected} />
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
\${agents.length === 0 ? html\`
|
|
391
|
+
<div class="empty-state" style="padding: 3rem;">
|
|
392
|
+
<div style="font-size: 1.5rem; color: var(--text-dim); margin-bottom: 1rem;">No Agent Activity</div>
|
|
393
|
+
<div style="color: var(--text-dim); max-width: 400px; margin: 0 auto; line-height: 1.6;">
|
|
394
|
+
Agents appear here when they emit MAP events.<br/>
|
|
395
|
+
Run <span style="color: var(--accent); font-family: monospace;">jfl peter</span> or
|
|
396
|
+
<span style="color: var(--accent); font-family: monospace;">jfl ralph</span> to see them in action.
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
\` : html\`
|
|
400
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
|
401
|
+
<\${Card} title=\${'Local Agents (' + localAgents.length + ')'}>
|
|
402
|
+
\${localAgents.map(a => html\`
|
|
403
|
+
<\${AgentCard} key=\${a.name} agent=\${a} />
|
|
404
|
+
\`)}
|
|
405
|
+
\${localAgents.length === 0 && html\`<div style="color: var(--text-dim); font-size: 0.8rem;">None</div>\`}
|
|
406
|
+
<//>
|
|
407
|
+
<\${Card} title=\${'Remote Agents (' + remoteAgents.length + ')'}>
|
|
408
|
+
\${remoteAgents.map(a => html\`
|
|
409
|
+
<\${AgentCard} key=\${a.name} agent=\${a} />
|
|
410
|
+
\`)}
|
|
411
|
+
\${remoteAgents.length === 0 && html\`<div style="color: var(--text-dim); font-size: 0.8rem;">None</div>\`}
|
|
412
|
+
<//>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
\${peterEvents.length > 0 && html\`
|
|
416
|
+
<\${Card} title="Peter Parker Routing" style="margin-top: 1rem;">
|
|
417
|
+
<div style="max-height: 300px; overflow-y: auto;">
|
|
418
|
+
<table>
|
|
419
|
+
<thead>
|
|
420
|
+
<tr>
|
|
421
|
+
<th>Time</th>
|
|
422
|
+
<th>Action</th>
|
|
423
|
+
<th>Details</th>
|
|
424
|
+
</tr>
|
|
425
|
+
</thead>
|
|
426
|
+
<tbody>
|
|
427
|
+
\${peterEvents.slice(0, 30).map(e => html\`
|
|
428
|
+
<tr key=\${e.id} class="event-row">
|
|
429
|
+
<td class="event-time">\${new Date(e.ts || e.timestamp).toLocaleTimeString()}</td>
|
|
430
|
+
<td><\${TypeBadge} type=\${e.type} /></td>
|
|
431
|
+
<td class="event-payload">
|
|
432
|
+
\${e.data?.task || e.data?.message || e.data?.agentRole || JSON.stringify(e.data || {}).slice(0, 80)}
|
|
433
|
+
</td>
|
|
434
|
+
</tr>
|
|
435
|
+
\`)}
|
|
436
|
+
</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</div>
|
|
439
|
+
<//>
|
|
440
|
+
\`}
|
|
441
|
+
\`}
|
|
442
|
+
</div>
|
|
443
|
+
\`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function AgentCard({ agent }) {
|
|
447
|
+
const a = agent
|
|
448
|
+
const age = a.lastSeen ? Math.round((Date.now() - new Date(a.lastSeen).getTime()) / 1000) : null
|
|
449
|
+
const ageStr = age !== null
|
|
450
|
+
? age < 60 ? age + 's ago'
|
|
451
|
+
: age < 3600 ? Math.round(age / 60) + 'm ago'
|
|
452
|
+
: Math.round(age / 3600) + 'h ago'
|
|
453
|
+
: ''
|
|
454
|
+
|
|
455
|
+
return html\`
|
|
456
|
+
<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid rgba(51,65,85,0.3);">
|
|
457
|
+
<div style="display: flex; align-items: center; gap: 0.625rem;">
|
|
458
|
+
<span class=\${'agent-dot agent-dot-' + a.status}></span>
|
|
459
|
+
<div>
|
|
460
|
+
<div style="font-size: 0.85rem; font-weight: 600;">\${a.name}</div>
|
|
461
|
+
\${a.role && html\`<div style="font-size: 0.7rem; color: var(--text-dim);">\${a.role}</div>\`}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
<div style="display: flex; align-items: center; gap: 0.5rem; text-align: right;">
|
|
465
|
+
\${a.model && html\`<span class="model-badge">\${a.model}</span>\`}
|
|
466
|
+
\${a.runtime && a.runtime !== 'local' && html\`<span class="runtime-badge">\${a.runtime}</span>\`}
|
|
467
|
+
<span style="font-size: 0.65rem; color: var(--text-dim); min-width: 50px;">\${ageStr}</span>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
\`
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ================================================================
|
|
474
|
+
// PROJECTS (cross-project health)
|
|
475
|
+
// ================================================================
|
|
476
|
+
|
|
477
|
+
function ProjectsPage() {
|
|
478
|
+
const [projects, setProjects] = useState(null)
|
|
479
|
+
const [loading, setLoading] = useState(true)
|
|
480
|
+
const [projectJournal, setProjectJournal] = useState({})
|
|
481
|
+
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
async function load() {
|
|
484
|
+
try {
|
|
485
|
+
const data = await apiFetch('/api/projects')
|
|
486
|
+
setProjects(data)
|
|
487
|
+
} catch (err) {
|
|
488
|
+
console.error('Failed to load projects:', err)
|
|
489
|
+
} finally {
|
|
490
|
+
setLoading(false)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
load()
|
|
494
|
+
const interval = setInterval(load, 30000)
|
|
495
|
+
|
|
496
|
+
// Fetch journal for entry counts
|
|
497
|
+
apiFetch('/api/context', {
|
|
498
|
+
method: 'POST',
|
|
499
|
+
body: JSON.stringify({ maxItems: 500 })
|
|
500
|
+
})
|
|
501
|
+
.then(data => {
|
|
502
|
+
const entries = (data.items || []).filter(i => i.source === 'journal')
|
|
503
|
+
const bySession = {}
|
|
504
|
+
for (const e of entries) {
|
|
505
|
+
const session = (e.path || '').split('/').pop() || 'unknown'
|
|
506
|
+
const project = session.replace('.jsonl', '').replace(/^session-/, '')
|
|
507
|
+
if (!bySession[project]) bySession[project] = { count: 0, lastTs: '' }
|
|
508
|
+
bySession[project].count++
|
|
509
|
+
if (e.timestamp && e.timestamp > bySession[project].lastTs) {
|
|
510
|
+
bySession[project].lastTs = e.timestamp
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
setProjectJournal(bySession)
|
|
514
|
+
})
|
|
515
|
+
.catch(() => {})
|
|
516
|
+
|
|
517
|
+
return () => clearInterval(interval)
|
|
518
|
+
}, [])
|
|
519
|
+
|
|
520
|
+
if (loading) return html\`<div class="loading">Loading projects</div>\`
|
|
521
|
+
|
|
522
|
+
if (!projects || projects.length === 0) {
|
|
523
|
+
return html\`
|
|
524
|
+
<div>
|
|
525
|
+
<h2 class="page-title">Projects</h2>
|
|
526
|
+
<div class="empty-state">
|
|
527
|
+
<div style="color: var(--text-dim);">No tracked projects found.</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
\`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const ok = projects.filter(p => p.status === 'OK').length
|
|
534
|
+
const down = projects.filter(p => p.status === 'DOWN').length
|
|
535
|
+
const zombie = projects.filter(p => p.status === 'ZOMBIE').length
|
|
536
|
+
|
|
537
|
+
return html\`
|
|
538
|
+
<div>
|
|
539
|
+
<h2 class="page-title">Projects</h2>
|
|
540
|
+
<div class="grid-3" style="margin-bottom: 1rem;">
|
|
541
|
+
<\${Card} title="Healthy">
|
|
542
|
+
<div class="card-value" style="color: var(--success);">\${ok}</div>
|
|
543
|
+
<//>
|
|
544
|
+
<\${Card} title="Down">
|
|
545
|
+
<div class="card-value" style="color: \${down > 0 ? 'var(--error)' : 'var(--text-dim)'};">\${down}</div>
|
|
546
|
+
<//>
|
|
547
|
+
<\${Card} title="Zombie">
|
|
548
|
+
<div class="card-value" style="color: \${zombie > 0 ? 'var(--warning)' : 'var(--text-dim)'};">\${zombie}</div>
|
|
549
|
+
<//>
|
|
550
|
+
</div>
|
|
551
|
+
<\${Card}>
|
|
552
|
+
<table>
|
|
553
|
+
<thead>
|
|
554
|
+
<tr>
|
|
555
|
+
<th>Project</th>
|
|
556
|
+
<th>Port</th>
|
|
557
|
+
<th>Status</th>
|
|
558
|
+
<th>PID</th>
|
|
559
|
+
<th>Journal Entries</th>
|
|
560
|
+
<th>Last Active</th>
|
|
561
|
+
</tr>
|
|
562
|
+
</thead>
|
|
563
|
+
<tbody>
|
|
564
|
+
\${projects.map(p => {
|
|
565
|
+
const name = p.name || ''
|
|
566
|
+
const journalInfo = Object.entries(projectJournal).find(([k]) => name.toLowerCase().includes(k.toLowerCase().slice(0, 8)))
|
|
567
|
+
const entryCount = journalInfo ? journalInfo[1].count : '-'
|
|
568
|
+
const lastActive = journalInfo && journalInfo[1].lastTs
|
|
569
|
+
? new Date(journalInfo[1].lastTs).toLocaleDateString()
|
|
570
|
+
: '-'
|
|
571
|
+
return html\`
|
|
572
|
+
<tr key=\${p.path}>
|
|
573
|
+
<td style="font-weight: 600;">\${p.name}</td>
|
|
574
|
+
<td style="font-family: monospace; font-size: 0.8rem;">\${p.port}</td>
|
|
575
|
+
<td><\${StatusBadge} status=\${p.status} /></td>
|
|
576
|
+
<td style="font-family: monospace; font-size: 0.8rem; color: var(--text-soft);">\${p.pid || '-'}</td>
|
|
577
|
+
<td style="font-size: 0.8rem; color: var(--text-soft);">\${entryCount}</td>
|
|
578
|
+
<td style="font-size: 0.8rem; color: var(--text-soft);">\${lastActive}</td>
|
|
579
|
+
</tr>
|
|
580
|
+
\`
|
|
581
|
+
})}
|
|
582
|
+
</tbody>
|
|
583
|
+
</table>
|
|
584
|
+
<//>
|
|
585
|
+
</div>
|
|
586
|
+
\`
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ================================================================
|
|
590
|
+
// SESSIONS (journal-based)
|
|
591
|
+
// ================================================================
|
|
592
|
+
|
|
593
|
+
function SessionsPage() {
|
|
594
|
+
const { journal, loading } = useContextData()
|
|
595
|
+
|
|
596
|
+
// Group entries by session field
|
|
597
|
+
const sessions = new Map()
|
|
598
|
+
for (const entry of journal) {
|
|
599
|
+
// Extract session ID from path (filename) since journal entries are per-session files
|
|
600
|
+
const filename = (entry.path || '').split('/').pop() || ''
|
|
601
|
+
const sid = filename.replace('.jsonl', '') || 'unknown'
|
|
602
|
+
if (!sessions.has(sid)) {
|
|
603
|
+
sessions.set(sid, {
|
|
604
|
+
id: sid,
|
|
605
|
+
entries: [],
|
|
606
|
+
types: new Set(),
|
|
607
|
+
firstTs: entry.timestamp,
|
|
608
|
+
lastTs: entry.timestamp,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
const s = sessions.get(sid)
|
|
612
|
+
s.entries.push(entry)
|
|
613
|
+
s.types.add(entry.type)
|
|
614
|
+
if (entry.timestamp) {
|
|
615
|
+
if (!s.firstTs || entry.timestamp < s.firstTs) s.firstTs = entry.timestamp
|
|
616
|
+
if (!s.lastTs || entry.timestamp > s.lastTs) s.lastTs = entry.timestamp
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const sessionList = [...sessions.values()]
|
|
621
|
+
.sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''))
|
|
622
|
+
|
|
623
|
+
return html\`
|
|
624
|
+
<div>
|
|
625
|
+
<h2 class="page-title">Sessions</h2>
|
|
626
|
+
\${loading ? html\`<div class="loading">Loading sessions</div>\` :
|
|
627
|
+
sessionList.length === 0 ? html\`
|
|
628
|
+
<div class="empty-state" style="padding: 3rem;">
|
|
629
|
+
<div style="color: var(--text-dim);">No sessions found in journal.</div>
|
|
630
|
+
</div>
|
|
631
|
+
\` : html\`
|
|
632
|
+
<div style="margin-bottom: 1rem; font-size: 0.8rem; color: var(--text-dim);">
|
|
633
|
+
\${sessionList.length} sessions from journal entries
|
|
634
|
+
</div>
|
|
635
|
+
<div style="display: grid; gap: 0.75rem;">
|
|
636
|
+
\${sessionList.map(s => html\`
|
|
637
|
+
<\${Card} key=\${s.id}>
|
|
638
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
|
639
|
+
<div>
|
|
640
|
+
<div style="font-weight: 600; font-family: monospace; font-size: 0.85rem;">\${s.id}</div>
|
|
641
|
+
<div style="font-size: 0.75rem; color: var(--text-dim); margin-top: 0.25rem;">
|
|
642
|
+
\${s.firstTs ? new Date(s.firstTs).toLocaleString() : ''}
|
|
643
|
+
\${s.lastTs && s.lastTs !== s.firstTs ? ' — ' + new Date(s.lastTs).toLocaleTimeString() : ''}
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
<span style="font-size: 0.8rem; font-weight: 600; color: var(--text-soft);">
|
|
647
|
+
\${s.entries.length} entries
|
|
648
|
+
</span>
|
|
649
|
+
</div>
|
|
650
|
+
<div style="display: flex; gap: 0.375rem; margin-top: 0.5rem; flex-wrap: wrap;">
|
|
651
|
+
\${[...s.types].map(t => html\`
|
|
652
|
+
<\${TypeBadge} key=\${t} type=\${t} />
|
|
653
|
+
\`)}
|
|
654
|
+
</div>
|
|
655
|
+
<!-- Show last 3 entries as preview -->
|
|
656
|
+
<div style="margin-top: 0.625rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
|
|
657
|
+
\${s.entries.slice(0, 3).map(entry => html\`
|
|
658
|
+
<div key=\${entry.timestamp + entry.title} style="display: flex; gap: 0.5rem; align-items: baseline; padding: 0.2rem 0; font-size: 0.75rem;">
|
|
659
|
+
<span style="color: var(--text-dim); white-space: nowrap;">\${entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
|
|
660
|
+
<span style="color: var(--text-soft);">\${entry.title}</span>
|
|
661
|
+
</div>
|
|
662
|
+
\`)}
|
|
663
|
+
</div>
|
|
664
|
+
<//>
|
|
665
|
+
\`)}
|
|
666
|
+
</div>
|
|
667
|
+
\`
|
|
668
|
+
}
|
|
669
|
+
</div>
|
|
670
|
+
\`
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ================================================================
|
|
674
|
+
// JOURNAL (full feed with type filtering)
|
|
675
|
+
// ================================================================
|
|
676
|
+
|
|
677
|
+
function JournalPage() {
|
|
678
|
+
const { journal, loading } = useContextData()
|
|
679
|
+
const [typeFilter, setTypeFilter] = useState('')
|
|
680
|
+
const [searchFilter, setSearchFilter] = useState('')
|
|
681
|
+
|
|
682
|
+
const types = [...new Set(journal.map(e => e.type))].sort()
|
|
683
|
+
|
|
684
|
+
const filtered = journal.filter(entry => {
|
|
685
|
+
if (typeFilter && entry.type !== typeFilter) return false
|
|
686
|
+
if (searchFilter) {
|
|
687
|
+
const q = searchFilter.toLowerCase()
|
|
688
|
+
return (entry.title || '').toLowerCase().includes(q) ||
|
|
689
|
+
(entry.content || '').toLowerCase().includes(q)
|
|
690
|
+
}
|
|
691
|
+
return true
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
return html\`
|
|
695
|
+
<div>
|
|
696
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
697
|
+
<h2 class="page-title" style="margin-bottom: 0;">Journal</h2>
|
|
698
|
+
<span style="font-size: 0.8rem; color: var(--text-dim);">\${filtered.length} entries</span>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<div style="display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap;">
|
|
702
|
+
<\${SearchInput}
|
|
703
|
+
value=\${searchFilter}
|
|
704
|
+
onInput=\${e => setSearchFilter(e.target.value)}
|
|
705
|
+
placeholder="Search journal entries..."
|
|
706
|
+
/>
|
|
707
|
+
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap; align-items: center;">
|
|
708
|
+
<button
|
|
709
|
+
class=\${'btn ' + (!typeFilter ? 'btn-primary' : 'btn-secondary')}
|
|
710
|
+
style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
|
|
711
|
+
onClick=\${() => setTypeFilter('')}
|
|
712
|
+
>All</button>
|
|
713
|
+
\${types.map(t => html\`
|
|
714
|
+
<button
|
|
715
|
+
key=\${t}
|
|
716
|
+
class=\${'btn ' + (typeFilter === t ? 'btn-primary' : 'btn-secondary')}
|
|
717
|
+
style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
|
|
718
|
+
onClick=\${() => setTypeFilter(typeFilter === t ? '' : t)}
|
|
719
|
+
>\${t}</button>
|
|
720
|
+
\`)}
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
|
|
724
|
+
\${loading ? html\`<div class="loading">Loading journal</div>\` : html\`
|
|
725
|
+
<\${Card}>
|
|
726
|
+
<div style="max-height: 600px; overflow-y: auto;">
|
|
727
|
+
\${filtered.length === 0 ? html\`
|
|
728
|
+
<div class="empty-state">
|
|
729
|
+
<div style="color: var(--text-dim);">\${searchFilter || typeFilter ? 'No matching entries' : 'No journal entries'}</div>
|
|
730
|
+
</div>
|
|
731
|
+
\` : filtered.map(entry => html\`
|
|
732
|
+
<\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} expanded=\${true} />
|
|
733
|
+
\`)}
|
|
734
|
+
</div>
|
|
735
|
+
<//>
|
|
736
|
+
\`}
|
|
737
|
+
</div>
|
|
738
|
+
\`
|
|
739
|
+
}
|
|
740
|
+
`;
|
|
741
|
+
}
|
|
742
|
+
//# sourceMappingURL=pages.js.map
|