mcp-coordinator 0.1.0
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/LICENSE +21 -0
- package/README.md +92 -0
- package/dashboard/Dockerfile +19 -0
- package/dashboard/public/index.html +1178 -0
- package/dist/cli/config.d.ts +14 -0
- package/dist/cli/config.js +58 -0
- package/dist/cli/dashboard.d.ts +2 -0
- package/dist/cli/dashboard.js +14 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/server/index.d.ts +2 -0
- package/dist/cli/server/index.js +11 -0
- package/dist/cli/server/start.d.ts +2 -0
- package/dist/cli/server/start.js +57 -0
- package/dist/cli/server/status.d.ts +2 -0
- package/dist/cli/server/status.js +60 -0
- package/dist/cli/server/stop.d.ts +2 -0
- package/dist/cli/server/stop.js +59 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/src/agent-activity.d.ts +27 -0
- package/dist/src/agent-activity.js +70 -0
- package/dist/src/agent-registry.d.ts +10 -0
- package/dist/src/agent-registry.js +38 -0
- package/dist/src/auth.d.ts +22 -0
- package/dist/src/auth.js +91 -0
- package/dist/src/conflict-detector.d.ts +17 -0
- package/dist/src/conflict-detector.js +114 -0
- package/dist/src/consultation.d.ts +75 -0
- package/dist/src/consultation.js +332 -0
- package/dist/src/context-provider.d.ts +14 -0
- package/dist/src/context-provider.js +34 -0
- package/dist/src/database.d.ts +4 -0
- package/dist/src/database.js +194 -0
- package/dist/src/db-adapter.d.ts +15 -0
- package/dist/src/db-adapter.js +1 -0
- package/dist/src/dependency-map.d.ts +7 -0
- package/dist/src/dependency-map.js +76 -0
- package/dist/src/file-tracker.d.ts +21 -0
- package/dist/src/file-tracker.js +44 -0
- package/dist/src/impact-scorer.d.ts +31 -0
- package/dist/src/impact-scorer.js +112 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +26 -0
- package/dist/src/introspection.d.ts +24 -0
- package/dist/src/introspection.js +28 -0
- package/dist/src/logger.d.ts +20 -0
- package/dist/src/logger.js +55 -0
- package/dist/src/mqtt-bridge.d.ts +40 -0
- package/dist/src/mqtt-bridge.js +173 -0
- package/dist/src/mqtt-broker.d.ts +23 -0
- package/dist/src/mqtt-broker.js +99 -0
- package/dist/src/plan-quality.d.ts +11 -0
- package/dist/src/plan-quality.js +30 -0
- package/dist/src/quota/credential-reader.d.ts +21 -0
- package/dist/src/quota/credential-reader.js +86 -0
- package/dist/src/quota/quota-cache.d.ts +93 -0
- package/dist/src/quota/quota-cache.js +177 -0
- package/dist/src/quota/quota.d.ts +47 -0
- package/dist/src/quota/quota.js +117 -0
- package/dist/src/serve-http.d.ts +5 -0
- package/dist/src/serve-http.js +775 -0
- package/dist/src/server-setup.d.ts +34 -0
- package/dist/src/server-setup.js +453 -0
- package/dist/src/sse-emitter.d.ts +10 -0
- package/dist/src/sse-emitter.js +35 -0
- package/dist/src/types.d.ts +121 -0
- package/dist/src/types.js +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>MCP Coordinator v3 — Dashboard</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a14; color: #e2e8f0; }
|
|
9
|
+
.header { background: #0f0f1a; padding: 12px 20px; border-bottom: 1px solid #1e293b; display: flex; justify-content: space-between; align-items: center; }
|
|
10
|
+
.header h1 { font-size: 16px; font-weight: 600; }
|
|
11
|
+
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
|
12
|
+
.status-dot.online, .status-dot.idle { background: #4ade80; }
|
|
13
|
+
.status-dot.working { background: #60a5fa; animation: pulse 1.5s infinite; }
|
|
14
|
+
.status-dot.waiting { background: #fbbf24; animation: pulse 2s infinite; }
|
|
15
|
+
.status-dot.offline { background: #ef4444; }
|
|
16
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
17
|
+
.agent-activity { font-size: 10px; color: #94a3b8; margin-top: 2px; font-style: italic; }
|
|
18
|
+
.grid { display: grid; grid-template-columns: 250px 1fr 6px 300px; height: calc(100vh - 48px); }
|
|
19
|
+
.resize-handle { cursor: col-resize; background: transparent; position: relative; user-select: none; transition: background 0.15s; }
|
|
20
|
+
.resize-handle:hover { background: rgba(96, 165, 250, 0.2); }
|
|
21
|
+
.resize-handle.dragging { background: rgba(96, 165, 250, 0.4); }
|
|
22
|
+
.resize-handle::after { content: ''; position: absolute; left: 50%; top: 50%; width: 2px; height: 44px; background: #334155; transform: translate(-50%, -50%); border-radius: 1px; transition: background 0.15s; }
|
|
23
|
+
.resize-handle:hover::after, .resize-handle.dragging::after { background: #60a5fa; }
|
|
24
|
+
body.resizing { cursor: col-resize; user-select: none; }
|
|
25
|
+
body.resizing * { cursor: col-resize !important; user-select: none !important; }
|
|
26
|
+
.panel { background: #0f0f1a; border-right: 1px solid #1e293b; padding: 12px; overflow-y: auto; }
|
|
27
|
+
.panel:last-child { border-right: none; }
|
|
28
|
+
.panel-title { font-size: 11px; text-transform: uppercase; color: #64748b; margin-bottom: 8px; letter-spacing: 0.05em; }
|
|
29
|
+
.agent-card { background: #1a1a2e; border-radius: 6px; padding: 10px; margin-bottom: 6px; }
|
|
30
|
+
.agent-name { font-size: 13px; font-weight: 500; }
|
|
31
|
+
.agent-modules { font-size: 11px; color: #64748b; margin-top: 2px; }
|
|
32
|
+
.hot-file { background: #2d1a1a; border-radius: 4px; padding: 8px; margin-bottom: 4px; font-size: 12px; color: #f87171; }
|
|
33
|
+
.timeline { background: #0a0a14; padding: 12px; overflow-y: auto; }
|
|
34
|
+
.timeline-event { border-left: 2px solid #334155; margin-left: 12px; padding: 0 0 12px 16px; }
|
|
35
|
+
.timeline-time { font-size: 10px; color: #64748b; }
|
|
36
|
+
.timeline-content { border-radius: 6px; padding: 10px; margin-top: 2px; font-size: 12px; }
|
|
37
|
+
.event-thread_opened .timeline-content { background: #1e3a5f; }
|
|
38
|
+
.event-message_posted .timeline-content { background: #2d1a3a; }
|
|
39
|
+
.event-resolution_proposed .timeline-content { background: #3b2a1a; }
|
|
40
|
+
.event-thread_resolved .timeline-content { background: #1a3a2e; }
|
|
41
|
+
.event-thread_cancelled .timeline-content { background: #3b1a1a; }
|
|
42
|
+
.event-file_edited .timeline-content { background: #1a1a2e; }
|
|
43
|
+
.event-agent_online .timeline-content { background: #1a3a2e; }
|
|
44
|
+
.event-agent_offline .timeline-content { background: #3b1a1a; }
|
|
45
|
+
.event-action_summary .timeline-content { background: #1a2a3a; }
|
|
46
|
+
.event-impact_scored .timeline-content { background: #12121f; border: 1px dashed #2a2a3e; }
|
|
47
|
+
.event-introspection_requested .timeline-content { background: #2a1a2e; border: 1px dashed #4a2a4e; }
|
|
48
|
+
.event-introspection_completed { display: none; }
|
|
49
|
+
body.verbose .event-introspection_completed { display: block; }
|
|
50
|
+
.event-introspection_completed .timeline-content { background: #1a2a1a; border: 1px dashed #2a4a2a; }
|
|
51
|
+
.event-impact_scored { display: none; }
|
|
52
|
+
.event-introspection_requested { display: none; }
|
|
53
|
+
body.verbose .event-impact_scored { display: block; }
|
|
54
|
+
body.verbose .event-introspection_requested { display: block; }
|
|
55
|
+
.event-quota_update .timeline-content { background: #12121f; border: 1px dashed #2a2a3e; }
|
|
56
|
+
.event-verbose .timeline-content { background: #12121f; border: 1px dashed #2a2a3e; }
|
|
57
|
+
.event-verbose .event-type { color: #64748b; }
|
|
58
|
+
.event-verbose { display: none; }
|
|
59
|
+
body.verbose .event-verbose { display: block; }
|
|
60
|
+
.msg-full { display: none; white-space: pre-wrap; }
|
|
61
|
+
body.verbose .msg-short { display: none; }
|
|
62
|
+
body.verbose .msg-full { display: inline; }
|
|
63
|
+
#btn-verbose.active { background: #334155; color: #e2e8f0; border-color: #60a5fa; }
|
|
64
|
+
.event-type { font-weight: 600; margin-right: 6px; }
|
|
65
|
+
.metric { background: #1a1a2e; border-radius: 6px; padding: 12px; text-align: center; margin-bottom: 8px; }
|
|
66
|
+
.metric-value { font-size: 24px; font-weight: 700; }
|
|
67
|
+
.metric-label { font-size: 10px; color: #64748b; text-transform: uppercase; margin-top: 2px; }
|
|
68
|
+
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
69
|
+
.thread-card { background: #1a1a2e; border-radius: 6px; padding: 12px; margin-bottom: 8px; }
|
|
70
|
+
.thread-subject { font-size: 13px; font-weight: 600; color: #60a5fa; }
|
|
71
|
+
.thread-meta { font-size: 11px; color: #64748b; margin-top: 4px; }
|
|
72
|
+
.thread-status { font-size: 11px; margin-top: 2px; }
|
|
73
|
+
.thread-status.open { color: #fbbf24; }
|
|
74
|
+
.thread-status.resolving { color: #c084fc; }
|
|
75
|
+
.thread-status.resolved { color: #4ade80; }
|
|
76
|
+
.connection-status { font-size: 12px; }
|
|
77
|
+
#no-events { text-align: center; color: #64748b; padding: 40px; font-size: 13px; }
|
|
78
|
+
.config-section { background: #111122; border: 1px solid #1e293b; border-radius: 6px; padding: 10px; margin-top: 12px; }
|
|
79
|
+
.config-row { display: flex; justify-content: space-between; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e; }
|
|
80
|
+
.config-row:last-child { border-bottom: none; }
|
|
81
|
+
.config-key { color: #64748b; }
|
|
82
|
+
.config-val { color: #e2e8f0; font-weight: 500; }
|
|
83
|
+
.config-title { font-size: 12px; font-weight: 600; color: #60a5fa; margin-bottom: 6px; }
|
|
84
|
+
.config-agents { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
|
85
|
+
.config-agent-tag { background: #1a2a3a; border-radius: 3px; padding: 2px 6px; font-size: 10px; color: #94a3b8; }
|
|
86
|
+
|
|
87
|
+
/* ── Consultations actives (zones fixed + contextual) ─────────────── */
|
|
88
|
+
.filter-bar { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
|
89
|
+
.filter-chip { font-size: 10px; padding: 2px 8px; border-radius: 10px; background: #1a1a2e; border: 1px solid #2a2a3e; color: #64748b; cursor: pointer; user-select: none; }
|
|
90
|
+
.filter-chip.active { background: #1e293b; border-color: #60a5fa; color: #e2e8f0; }
|
|
91
|
+
.filter-chip:hover { border-color: #475569; }
|
|
92
|
+
|
|
93
|
+
.thread-card { background: #1a1a2e; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; transition: opacity .3s; position: relative; }
|
|
94
|
+
.thread-card.stale { opacity: 0.55; }
|
|
95
|
+
.thread-card.flash { animation: cardFlash 1.2s ease-out; }
|
|
96
|
+
@keyframes cardFlash { 0% { box-shadow: 0 0 0 2px #60a5fa; } 100% { box-shadow: 0 0 0 2px transparent; } }
|
|
97
|
+
|
|
98
|
+
.thread-zone-fixed { padding-bottom: 6px; border-bottom: 1px solid #252538; margin-bottom: 6px; }
|
|
99
|
+
.thread-zone-ctx { font-size: 11px; color: #94a3b8; display: flex; flex-direction: column; gap: 3px; }
|
|
100
|
+
.thread-zone-ctx > div { line-height: 1.4; }
|
|
101
|
+
|
|
102
|
+
.thread-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
|
|
103
|
+
.thread-head-left { flex: 1; min-width: 0; }
|
|
104
|
+
.thread-head-right { font-size: 10px; color: #64748b; white-space: nowrap; text-align: right; }
|
|
105
|
+
.thread-initiator { font-size: 10px; color: #64748b; margin-top: 3px; }
|
|
106
|
+
.status-pill { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 10px; font-weight: 600; }
|
|
107
|
+
.status-pill.open { background: #2a230a; color: #fbbf24; }
|
|
108
|
+
.status-pill.resolving { background: #2a1a3a; color: #c084fc; }
|
|
109
|
+
.status-pill.resolved { background: #0a2a18; color: #4ade80; }
|
|
110
|
+
.status-pill.cancelled { background: #2a0a0a; color: #f87171; }
|
|
111
|
+
|
|
112
|
+
.severity-pill { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 3px; font-weight: 600; text-transform: uppercase; }
|
|
113
|
+
.severity-pill.critical { background: #3b1a1a; color: #f87171; }
|
|
114
|
+
.severity-pill.major { background: #3b2a1a; color: #fbbf24; }
|
|
115
|
+
.severity-pill.minor { background: #1a2a3a; color: #94a3b8; }
|
|
116
|
+
|
|
117
|
+
.thread-chip { display: inline-flex; align-items: center; gap: 4px; }
|
|
118
|
+
.countdown.urgent { color: #f87171; font-weight: 600; }
|
|
119
|
+
.countdown.warning { color: #fbbf24; }
|
|
120
|
+
.rel, .countdown { font-variant-numeric: tabular-nums; }
|
|
121
|
+
.msg-preview { color: #cbd5e1; font-style: italic; }
|
|
122
|
+
|
|
123
|
+
.profile-badge { font-size: 9px; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; margin-left: 4px; }
|
|
124
|
+
.profile-badge.codeur { background: #0a2a2a; color: #5eead4; }
|
|
125
|
+
.profile-badge.communicant { background: #2a0a2a; color: #f0abfc; }
|
|
126
|
+
|
|
127
|
+
/* ── Token Budget panel ────────────────────────────────────────── */
|
|
128
|
+
.token-total { background: #0f0f1a; border: 1px solid #1e293b; border-radius: 6px; padding: 10px; margin-bottom: 8px; }
|
|
129
|
+
.token-total-row { display: flex; justify-content: space-between; font-size: 11px; margin: 2px 0; }
|
|
130
|
+
.token-total-row.big { font-size: 14px; font-weight: 600; color: #fbbf24; margin-bottom: 6px; }
|
|
131
|
+
.token-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-top: 4px; background: #1a1a2e; }
|
|
132
|
+
.token-bar-input { background: #60a5fa; }
|
|
133
|
+
.token-bar-output { background: #f472b6; }
|
|
134
|
+
.token-bar-cache-r { background: #4ade80; }
|
|
135
|
+
.token-bar-cache-w { background: #fbbf24; }
|
|
136
|
+
|
|
137
|
+
.token-agent { background: #1a1a2e; border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; }
|
|
138
|
+
.token-agent-name { font-size: 12px; font-weight: 600; color: #60a5fa; margin-bottom: 4px; }
|
|
139
|
+
.token-agent-row { display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8; }
|
|
140
|
+
.token-phase-chip { display: inline-block; font-size: 9px; padding: 1px 4px; border-radius: 3px; background: #1e293b; color: #cbd5e1; margin-right: 3px; }
|
|
141
|
+
.token-phase-chip .cost { color: #fbbf24; margin-left: 2px; }
|
|
142
|
+
.cache-good { color: #4ade80; }
|
|
143
|
+
.cache-meh { color: #fbbf24; }
|
|
144
|
+
.cache-bad { color: #f87171; }
|
|
145
|
+
|
|
146
|
+
/* Quota widget */
|
|
147
|
+
.quota-panel { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
|
148
|
+
.quota-bucket { background: #1a1a2e; border-radius: 6px; padding: 8px 10px; border: 1px solid #334155; }
|
|
149
|
+
.quota-bucket.warn { border-color: #fbbf24; background: #2e2518; }
|
|
150
|
+
.quota-bucket.danger { border-color: #f87171; background: #2e1a1a; }
|
|
151
|
+
.quota-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
152
|
+
.quota-value { font-size: 18px; font-weight: 700; color: #e2e8f0; margin-top: 2px; }
|
|
153
|
+
.quota-reset { font-size: 10px; color: #64748b; margin-top: 2px; }
|
|
154
|
+
.quota-bar { background: #0f172a; border-radius: 3px; height: 4px; margin-top: 6px; overflow: hidden; }
|
|
155
|
+
.quota-bar-fill { height: 100%; background: #4ade80; transition: width 0.3s, background 0.3s; }
|
|
156
|
+
.quota-bar-fill.warn { background: #fbbf24; }
|
|
157
|
+
.quota-bar-fill.danger { background: #f87171; }
|
|
158
|
+
.quota-unavailable { color: #64748b; font-style: italic; font-size: 11px; padding: 10px; text-align: center; }
|
|
159
|
+
</style>
|
|
160
|
+
</head>
|
|
161
|
+
<body>
|
|
162
|
+
<div class="header">
|
|
163
|
+
<h1>MCP Coordinator <span id="server-version" style="font-size:11px;font-weight:400;color:#64748b;margin-left:6px;">…</span></h1>
|
|
164
|
+
<span class="connection-status" id="conn-status">
|
|
165
|
+
<span class="status-dot offline" id="conn-dot"></span> Connecting...
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="grid">
|
|
169
|
+
<!-- Left panel: Agents + Hot Files + Quota -->
|
|
170
|
+
<div class="panel">
|
|
171
|
+
<div class="panel-title">Agents en ligne</div>
|
|
172
|
+
<div id="agents-list"></div>
|
|
173
|
+
<div class="panel-title" style="margin-top:16px;">Hot Files</div>
|
|
174
|
+
<div id="hot-files"></div>
|
|
175
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;">
|
|
176
|
+
<div class="panel-title" style="margin-bottom:0;">Quota Anthropic</div>
|
|
177
|
+
<button id="quota-refresh-btn" onclick="refreshQuota()" title="Force refresh from Anthropic (bypass cache)"
|
|
178
|
+
style="background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer;">
|
|
179
|
+
↻ Refresh
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
<div id="quota-widget"><div class="quota-unavailable">En attente…</div></div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Center: Timeline -->
|
|
186
|
+
<div class="timeline" id="timeline">
|
|
187
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
|
|
188
|
+
<div class="panel-title" style="margin-bottom:0;">Timeline</div>
|
|
189
|
+
<div style="display:flex;gap:6px;">
|
|
190
|
+
<button id="btn-verbose" onclick="toggleVerbose()" style="background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer;">Détails</button>
|
|
191
|
+
<button onclick="clearTimeline()" style="background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer;">Clear</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="no-events">En attente d'événements...</div>
|
|
195
|
+
<div id="events"></div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- Resize handle — drag to adjust right panel width -->
|
|
199
|
+
<div class="resize-handle" id="resize-right" title="Drag to resize"></div>
|
|
200
|
+
|
|
201
|
+
<!-- Right panel: Active thread + Metrics -->
|
|
202
|
+
<div class="panel">
|
|
203
|
+
<div class="panel-title">Consultations actives</div>
|
|
204
|
+
<div class="filter-bar" id="thread-filters"></div>
|
|
205
|
+
<div id="threads-list"></div>
|
|
206
|
+
<div class="panel-title" style="margin-top:16px;">Métriques</div>
|
|
207
|
+
<div class="metrics-grid" id="metrics">
|
|
208
|
+
<div class="metric"><div class="metric-value" id="m-threads">0</div><div class="metric-label">Consultations</div></div>
|
|
209
|
+
<div class="metric"><div class="metric-value" id="m-conflicts">0</div><div class="metric-label">Conflits</div></div>
|
|
210
|
+
<div class="metric"><div class="metric-value" id="m-time">-</div><div class="metric-label">Temps moyen</div></div>
|
|
211
|
+
<div class="metric"><div class="metric-value" id="m-consensus">-</div><div class="metric-label">Consensus</div></div>
|
|
212
|
+
<div class="metric"><div class="metric-value" id="m-tokens">0</div><div class="metric-label">Tokens consultation</div></div>
|
|
213
|
+
<div class="metric"><div class="metric-value" id="m-auto">0</div><div class="metric-label">Auto-résolu</div></div>
|
|
214
|
+
<div class="metric"><div class="metric-value" id="m-timeout">0</div><div class="metric-label">Timeout</div></div>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="panel-title" style="margin-top:16px;">Token Budget</div>
|
|
217
|
+
<div id="token-total"></div>
|
|
218
|
+
<div id="token-agents"></div>
|
|
219
|
+
|
|
220
|
+
<div class="panel-title" style="margin-top:16px;">Configuration</div>
|
|
221
|
+
<div id="run-config">
|
|
222
|
+
<div style="color:#64748b;font-size:12px;">Aucun run actif</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<script>
|
|
228
|
+
const COORDINATOR_URL = 'http://localhost:3100';
|
|
229
|
+
const FILTER_CATEGORIES = ['workflow', 'activity', 'impact', 'coordination', 'tokens'];
|
|
230
|
+
const FILTER_LABELS = { workflow: 'Workflow', activity: 'Activity', impact: 'Impact', coordination: 'Coordination', tokens: 'Tokens' };
|
|
231
|
+
const state = {
|
|
232
|
+
agents: {},
|
|
233
|
+
threads: {},
|
|
234
|
+
events: [],
|
|
235
|
+
metrics: { threads: 0, conflicts: 0, resolved: 0, totalTime: 0, totalTokens: 0, auto_resolved: 0, timeout: 0, consensus: 0 },
|
|
236
|
+
seenIds: new Set(),
|
|
237
|
+
threadStartTimes: {},
|
|
238
|
+
verbose: false,
|
|
239
|
+
// Alive-consultations state
|
|
240
|
+
threadsCache: {}, // id → last fetched thread object
|
|
241
|
+
threadActivity: {}, // id → { messageCount, lastMessageAt, lastMessageText, lastMessageType, lastAgentName, tokens }
|
|
242
|
+
threadPrevStatus: {}, // id → last-known status (for flash detection)
|
|
243
|
+
filters: new Set(loadFilters()),
|
|
244
|
+
// Per-agent token accounting, aggregated from token_usage SSE events
|
|
245
|
+
tokens: {}, // agentId → { name, totalCost, totalInput, totalOutput, cacheRead, cacheCreation, byPhase: {phase: {cost, input, output, cacheRead, cacheCreation, turns}}, byModel: {model: cost} }
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function loadFilters() {
|
|
249
|
+
try {
|
|
250
|
+
const raw = localStorage.getItem('dashboard.threadFilters');
|
|
251
|
+
if (raw) return JSON.parse(raw);
|
|
252
|
+
} catch {}
|
|
253
|
+
return FILTER_CATEGORIES; // default: all on
|
|
254
|
+
}
|
|
255
|
+
function saveFilters() {
|
|
256
|
+
localStorage.setItem('dashboard.threadFilters', JSON.stringify([...state.filters]));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Thread helpers: time, detection, formatting ─────────────────────
|
|
260
|
+
// Coordinator DB timestamps are "YYYY-MM-DD HH:MM:SS" in UTC but without
|
|
261
|
+
// the Z suffix — append it so Date() parses as UTC.
|
|
262
|
+
function parseDbTimestamp(s) {
|
|
263
|
+
if (!s) return null;
|
|
264
|
+
const withZ = s.includes('T') ? (s.endsWith('Z') ? s : s + 'Z') : (s.replace(' ', 'T') + 'Z');
|
|
265
|
+
const d = new Date(withZ);
|
|
266
|
+
return isNaN(d.getTime()) ? null : d;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatRelativeTime(ms) {
|
|
270
|
+
const diff = Math.max(0, Date.now() - ms);
|
|
271
|
+
const s = Math.floor(diff / 1000);
|
|
272
|
+
if (s < 60) return `${s}s`;
|
|
273
|
+
const m = Math.floor(s / 60);
|
|
274
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
275
|
+
const h = Math.floor(m / 60);
|
|
276
|
+
return `${h}h ${m % 60}m`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function formatCountdown(deadlineMs) {
|
|
280
|
+
const diff = deadlineMs - Date.now();
|
|
281
|
+
const sign = diff < 0 ? '-' : '';
|
|
282
|
+
const absS = Math.floor(Math.abs(diff) / 1000);
|
|
283
|
+
if (absS < 60) return `${sign}${absS}s`;
|
|
284
|
+
const m = Math.floor(absS / 60);
|
|
285
|
+
return `${sign}${m}m ${absS % 60}s`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function countdownClass(deadlineMs) {
|
|
289
|
+
const diff = deadlineMs - Date.now();
|
|
290
|
+
if (diff < 0) return 'urgent';
|
|
291
|
+
if (diff < 30_000) return 'urgent';
|
|
292
|
+
if (diff < 60_000) return 'warning';
|
|
293
|
+
return '';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseSeverity(subject) {
|
|
297
|
+
const m = (subject || '').match(/^(critical|major|minor)[:\s]/i);
|
|
298
|
+
return m ? m[1].toLowerCase() : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function displayName(agentId) {
|
|
302
|
+
if (!agentId) return '';
|
|
303
|
+
const a = state.agents[agentId];
|
|
304
|
+
return a?.name || agentId;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function detectThreadType(t) {
|
|
308
|
+
if (t.status === 'resolving') return 'resolving';
|
|
309
|
+
const respondents = JSON.parse(t.expected_respondents || '[]');
|
|
310
|
+
if (respondents.length > 0) return 'consultation';
|
|
311
|
+
// Work-stealing: either claimed or severity-tagged subject (from raid discoveries)
|
|
312
|
+
if (t.claimed_by || parseSeverity(t.subject)) return 'work_stealing';
|
|
313
|
+
return 'discovery'; // keep_open discovery with no severity prefix
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getInitiatorProfile(t) {
|
|
317
|
+
const a = state.agents[t.initiator_id];
|
|
318
|
+
return a?.profile || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatTokens(n) {
|
|
322
|
+
if (!n) return '0';
|
|
323
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
324
|
+
return String(n);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function toggleVerbose() {
|
|
328
|
+
state.verbose = !state.verbose;
|
|
329
|
+
document.body.classList.toggle('verbose', state.verbose);
|
|
330
|
+
document.getElementById('btn-verbose').classList.toggle('active', state.verbose);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function addVerboseEvent(text) {
|
|
334
|
+
const events = document.getElementById('events');
|
|
335
|
+
const div = document.createElement('div');
|
|
336
|
+
div.className = 'timeline-event event-verbose';
|
|
337
|
+
div.innerHTML = `<div class="timeline-time">${formatTime()}</div><div class="timeline-content"><span class="event-type">trace</span> ${text}</div>`;
|
|
338
|
+
events.prepend(div);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function formatTime(ts) {
|
|
342
|
+
return new Date(ts || Date.now()).toLocaleTimeString('fr-CA', { hour12: false });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function addEvent(type, payload, id) {
|
|
346
|
+
// Deduplicate: skip if already displayed
|
|
347
|
+
if (id && state.seenIds.has(id)) return;
|
|
348
|
+
if (id) state.seenIds.add(id);
|
|
349
|
+
|
|
350
|
+
const noEvents = document.getElementById('no-events');
|
|
351
|
+
if (noEvents) noEvents.remove();
|
|
352
|
+
|
|
353
|
+
const events = document.getElementById('events');
|
|
354
|
+
const div = document.createElement('div');
|
|
355
|
+
div.className = `timeline-event event-${type}`;
|
|
356
|
+
|
|
357
|
+
let label = type.replace(/_/g, ' ');
|
|
358
|
+
let detail = '';
|
|
359
|
+
const p = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
|
360
|
+
// _ts is injected by the server (event.created_at at emit time). When
|
|
361
|
+
// the page is refreshed or SSE backfills past events, this gives us the
|
|
362
|
+
// original timestamp instead of painting every event with "now".
|
|
363
|
+
const eventTs = p._ts ? Date.parse(p._ts.replace(' ', 'T') + (p._ts.endsWith('Z') ? '' : 'Z')) : null;
|
|
364
|
+
|
|
365
|
+
const agentName = p.agent_name || p.name || state.agents[p.agent_id]?.name || p.agent_id || '';
|
|
366
|
+
let agent = agentName ? `<span style="color:#60a5fa;font-weight:600;">${agentName}</span> ` : '';
|
|
367
|
+
|
|
368
|
+
if (type === 'thread_opened') {
|
|
369
|
+
// New thread → refetch active panel immediately
|
|
370
|
+
updateThreads();
|
|
371
|
+
const modules = (p.target_modules || []).join(', ');
|
|
372
|
+
const respondents = (p.expected_respondents || []);
|
|
373
|
+
const conflictCount = (p.conflicts || []).length;
|
|
374
|
+
const modeLabel = p.mode === 'with_plan'
|
|
375
|
+
? '<span style="background:#1a3a2e;color:#4ade80;font-size:9px;padding:1px 6px;border-radius:3px;margin-left:6px;">AVEC PLAN</span>'
|
|
376
|
+
: '<span style="background:#2a1a3a;color:#c084fc;font-size:9px;padding:1px 6px;border-radius:3px;margin-left:6px;">DISCOVERY</span>';
|
|
377
|
+
detail = `<strong>${p.subject || ''}</strong>${modeLabel}`;
|
|
378
|
+
if (p.plan) detail += `<div style="font-size:10px;color:#94a3b8;margin-top:3px;font-style:italic;">Plan: ${p.plan}</div>`;
|
|
379
|
+
detail += `<div style="font-size:10px;color:#8b8fa3;margin-top:3px;">Modules: ${modules}</div>`;
|
|
380
|
+
if (respondents.length) detail += `<div style="font-size:10px;color:#8b8fa3;">En attente: ${respondents.join(', ')}</div>`;
|
|
381
|
+
if (conflictCount) detail += `<div style="font-size:10px;color:#f87171;">⚠ ${conflictCount} conflit(s) détecté(s)</div>`;
|
|
382
|
+
state.metrics.threads++; if (conflictCount) state.metrics.conflicts += conflictCount;
|
|
383
|
+
if (p.thread_id) state.threadStartTimes[p.thread_id] = p.created_at ? new Date(p.created_at.replace(' ', 'T') + 'Z').getTime() : Date.now();
|
|
384
|
+
|
|
385
|
+
// Verbose: quorum from server-side impact scoring
|
|
386
|
+
if (respondents.length === 0) {
|
|
387
|
+
addVerboseEvent(`Quorum: <span style="color:#fbbf24;">0 répondant</span> → résolution instantanée`);
|
|
388
|
+
} else {
|
|
389
|
+
addVerboseEvent(`Quorum: <span style="color:#fbbf24;">${respondents.length} répondant(s) attendu(s)</span> — ${respondents.join(', ')}`);
|
|
390
|
+
}
|
|
391
|
+
// Verbose: plan quality assessment
|
|
392
|
+
if (p.plan_quality) {
|
|
393
|
+
const q = p.plan_quality;
|
|
394
|
+
const checks = [
|
|
395
|
+
q.checks.mentions_files ? '✓ fichiers' : '✗ fichiers',
|
|
396
|
+
q.checks.concrete_approach ? '✓ approche concrète' : '✗ approche vague',
|
|
397
|
+
q.checks.sufficient_detail ? '✓ détaillé' : '✗ trop court',
|
|
398
|
+
].join(', ');
|
|
399
|
+
addVerboseEvent(`Plan quality: ${q.score}/3 (${checks}) → mode ${q.mode}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else if (type === 'message_posted') {
|
|
403
|
+
const roundInfo = p.round ? ` <span style="color:#64748b;font-size:10px;">R${p.round}</span>` : '';
|
|
404
|
+
const fullContent = p.content || '';
|
|
405
|
+
if (fullContent.length > 200) {
|
|
406
|
+
const short = fullContent.substring(0, 200) + '…';
|
|
407
|
+
detail = `[${p.type}]${roundInfo} <span class="msg-short">${short}</span><span class="msg-full">${fullContent}</span>`;
|
|
408
|
+
} else {
|
|
409
|
+
detail = `[${p.type}]${roundInfo} ${fullContent}`;
|
|
410
|
+
}
|
|
411
|
+
if (p.token_estimate) state.metrics.totalTokens = (state.metrics.totalTokens || 0) + p.token_estimate;
|
|
412
|
+
// Update thread activity cache for the live Consultations panel
|
|
413
|
+
if (p.thread_id) {
|
|
414
|
+
const a = state.threadActivity[p.thread_id] || { messageCount: 0, tokens: 0 };
|
|
415
|
+
a.messageCount++;
|
|
416
|
+
a.lastMessageAt = Date.now();
|
|
417
|
+
a.lastMessageText = fullContent;
|
|
418
|
+
a.lastMessageType = p.type;
|
|
419
|
+
a.lastAgentName = p.agent_name || p.agent_id;
|
|
420
|
+
a.tokens = (a.tokens || 0) + (p.token_estimate || 0);
|
|
421
|
+
state.threadActivity[p.thread_id] = a;
|
|
422
|
+
renderThreads();
|
|
423
|
+
}
|
|
424
|
+
addVerboseEvent(`<span style="color:#60a5fa;font-weight:600;">${agentName}</span> a posté dans le thread (type: ${p.type}, round: ${p.round || '?'})`);
|
|
425
|
+
}
|
|
426
|
+
else if (type === 'resolution_proposed') {
|
|
427
|
+
detail = p.summary || '';
|
|
428
|
+
// Instant visual update: status change → refetch threads
|
|
429
|
+
if (p.thread_id) updateThreads();
|
|
430
|
+
addVerboseEvent(`<span style="color:#60a5fa;font-weight:600;">${agentName}</span> propose la résolution — thread passe à "resolving", en attente d'approbation`);
|
|
431
|
+
}
|
|
432
|
+
else if (type === 'thread_resolved') {
|
|
433
|
+
// Cleanup activity cache + refetch panel (thread leaves "active" list)
|
|
434
|
+
if (p.thread_id) {
|
|
435
|
+
delete state.threadActivity[p.thread_id];
|
|
436
|
+
delete state.threadPrevStatus[p.thread_id];
|
|
437
|
+
updateThreads();
|
|
438
|
+
}
|
|
439
|
+
const approver = p.approved_by_name || p.approved_by || '';
|
|
440
|
+
const resType = p.resolution_type || 'unknown';
|
|
441
|
+
detail = p.resolution || '';
|
|
442
|
+
if (approver) detail += `<div style="font-size:10px;color:#4ade80;margin-top:2px;">Approuvé par: ${approver}</div>`;
|
|
443
|
+
if (resType !== 'consensus') detail += `<div style="font-size:10px;color:#94a3b8;margin-top:2px;">Type: ${resType}</div>`;
|
|
444
|
+
agent = '';
|
|
445
|
+
state.metrics.resolved++;
|
|
446
|
+
|
|
447
|
+
// Track by resolution type
|
|
448
|
+
if (resType === 'auto_resolved') state.metrics.auto_resolved++;
|
|
449
|
+
else if (resType === 'timeout') state.metrics.timeout++;
|
|
450
|
+
else if (resType === 'consensus') state.metrics.consensus++;
|
|
451
|
+
|
|
452
|
+
// Calculate elapsed time only for threads with actual messages
|
|
453
|
+
if (p.thread_id && p.had_messages) {
|
|
454
|
+
let elapsed = 0;
|
|
455
|
+
if (p.created_at && p.resolved_at) {
|
|
456
|
+
elapsed = new Date(p.resolved_at.replace(' ', 'T') + 'Z').getTime() - new Date(p.created_at.replace(' ', 'T') + 'Z').getTime();
|
|
457
|
+
} else if (state.threadStartTimes[p.thread_id]) {
|
|
458
|
+
elapsed = Date.now() - state.threadStartTimes[p.thread_id];
|
|
459
|
+
}
|
|
460
|
+
if (elapsed > 0) {
|
|
461
|
+
state.metrics.totalTime += elapsed;
|
|
462
|
+
addVerboseEvent(`Consensus atteint en ${(elapsed/1000).toFixed(1)}s — tous les répondants ont approuvé`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (resType === 'auto_resolved') {
|
|
466
|
+
addVerboseEvent(`Thread auto-résolu (aucun agent concerné)`);
|
|
467
|
+
} else if (resType === 'timeout') {
|
|
468
|
+
addVerboseEvent(`Thread résolu par timeout — pas de réponse dans le délai`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else if (type === 'agent_online') {
|
|
472
|
+
const modules = (p.modules || []).join(', ');
|
|
473
|
+
detail = modules ? `<span style="font-size:10px;color:#64748b;">${modules}</span>` : '';
|
|
474
|
+
state.agents[p.agent_id] = { ...state.agents[p.agent_id], ...p, activity_status: 'idle' };
|
|
475
|
+
addVerboseEvent(`<span style="color:#60a5fa;font-weight:600;">${agentName}</span> enregistré avec modules: [${modules}]`);
|
|
476
|
+
}
|
|
477
|
+
else if (type === 'agent_offline') {
|
|
478
|
+
detail = '';
|
|
479
|
+
if (state.agents[p.agent_id]) state.agents[p.agent_id].activity_status = 'offline';
|
|
480
|
+
addVerboseEvent(`<span style="color:#60a5fa;font-weight:600;">${agentName}</span> déconnecté — retiré des quorums actifs`);
|
|
481
|
+
}
|
|
482
|
+
else if (type === 'agent_activity') {
|
|
483
|
+
if (state.agents[p.agent_id]) {
|
|
484
|
+
state.agents[p.agent_id].activity_status = p.activity_status;
|
|
485
|
+
state.agents[p.agent_id].current_file = p.current_file;
|
|
486
|
+
state.agents[p.agent_id].current_thread = p.current_thread;
|
|
487
|
+
}
|
|
488
|
+
const statusLabel = { working: '💻 au travail', idle: '⏸ en attente', waiting: '⏳ consultation', offline: '🔴 hors ligne' }[p.activity_status] || p.activity_status;
|
|
489
|
+
detail = `${statusLabel}${p.current_file ? ' — ' + p.current_file : ''}`;
|
|
490
|
+
}
|
|
491
|
+
else if (type === 'file_edited') {
|
|
492
|
+
const tool = p.tool_name ? `<span style="color:#64748b;font-size:10px;">[${p.tool_name}]</span> ` : '';
|
|
493
|
+
detail = `${tool}${p.file || ''}`;
|
|
494
|
+
}
|
|
495
|
+
else if (type === 'action_summary') { detail = p.summary || ''; }
|
|
496
|
+
else if (type === 'impact_scored') {
|
|
497
|
+
const category = p.score >= 90 ? '<span style="color:#4ade80;">concerné</span>' : p.score >= 30 ? '<span style="color:#fbbf24;">zone grise</span>' : '<span style="color:#64748b;">passe</span>';
|
|
498
|
+
detail = `score ${p.score} → ${category} (${p.reasons?.join(', ') || 'aucune raison'})`;
|
|
499
|
+
}
|
|
500
|
+
else if (type === 'introspection_requested') {
|
|
501
|
+
detail = `score ${p.score} — introspection demandée (${p.reasons?.join(', ') || ''})`;
|
|
502
|
+
}
|
|
503
|
+
else if (type === 'introspection_completed') {
|
|
504
|
+
const result = p.concerned ? '<span style="color:#4ade80;">CONCERN\u00c9</span>' : '<span style="color:#64748b;">PASSE</span>';
|
|
505
|
+
detail = `introspection: "${p.reason}" \u2192 ${result}`;
|
|
506
|
+
}
|
|
507
|
+
else if (type === 'run_config') {
|
|
508
|
+
renderRunConfig(p);
|
|
509
|
+
return; // don't add to timeline
|
|
510
|
+
}
|
|
511
|
+
else if (type === 'token_usage') {
|
|
512
|
+
trackTokenUsage(p);
|
|
513
|
+
return; // telemetry, not a timeline event
|
|
514
|
+
}
|
|
515
|
+
else if (type === 'quota_update') {
|
|
516
|
+
// Quota refresh event — hidden by default (verbose-only) since the
|
|
517
|
+
// widget in the left panel is the primary surface. One-line digest
|
|
518
|
+
// when Détails is on, so it's easy to trace refresh timing.
|
|
519
|
+
const fh = p.five_hour ? `${(p.five_hour.utilization ?? 0).toFixed(1)}%` : '?';
|
|
520
|
+
const sd = p.seven_day ? `${(p.seven_day.utilization ?? 0).toFixed(1)}%` : '?';
|
|
521
|
+
const resetMin = p.five_hour?.minutesUntilReset ?? 0;
|
|
522
|
+
const resetLabel = resetMin > 60
|
|
523
|
+
? `${Math.floor(resetMin / 60)}h${resetMin % 60 ? ' ' + (resetMin % 60) + 'min' : ''}`
|
|
524
|
+
: `${resetMin}min`;
|
|
525
|
+
detail = `5h ${fh} · 7j ${sd} · 5h-reset dans ${resetLabel}`;
|
|
526
|
+
agent = '';
|
|
527
|
+
}
|
|
528
|
+
else if (type === 'thread_cancelled') {
|
|
529
|
+
detail = p.reason ? `cancelled: ${p.reason}` : 'cancelled';
|
|
530
|
+
if (p.thread_id) {
|
|
531
|
+
delete state.threadActivity[p.thread_id];
|
|
532
|
+
delete state.threadPrevStatus[p.thread_id];
|
|
533
|
+
updateThreads();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else { detail = JSON.stringify(p).substring(0, 100); agent = ''; }
|
|
537
|
+
|
|
538
|
+
div.innerHTML = `<div class="timeline-time">${formatTime(eventTs)}</div><div class="timeline-content"><span class="event-type">${label}</span> ${agent}${detail}</div>`;
|
|
539
|
+
events.prepend(div);
|
|
540
|
+
updateMetrics();
|
|
541
|
+
updateAgents();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function updateMetrics() {
|
|
545
|
+
document.getElementById('m-threads').textContent = state.metrics.threads;
|
|
546
|
+
document.getElementById('m-conflicts').textContent = state.metrics.conflicts;
|
|
547
|
+
|
|
548
|
+
// Temps moyen = seulement les threads avec messages (vraies consultations)
|
|
549
|
+
const consultationsWithTime = state.metrics.consensus + state.metrics.timeout;
|
|
550
|
+
const avg = consultationsWithTime > 0 && state.metrics.totalTime > 0
|
|
551
|
+
? `${Math.round(state.metrics.totalTime / consultationsWithTime / 1000)}s`
|
|
552
|
+
: '-';
|
|
553
|
+
document.getElementById('m-time').textContent = avg;
|
|
554
|
+
|
|
555
|
+
// Consensus = consensus / total threads non-auto
|
|
556
|
+
const nonAuto = state.metrics.resolved - state.metrics.auto_resolved;
|
|
557
|
+
document.getElementById('m-consensus').textContent = nonAuto > 0
|
|
558
|
+
? `${state.metrics.consensus}/${nonAuto}`
|
|
559
|
+
: state.metrics.auto_resolved > 0 ? `${state.metrics.auto_resolved} auto` : '-';
|
|
560
|
+
|
|
561
|
+
document.getElementById('m-tokens').textContent = state.metrics.totalTokens > 0
|
|
562
|
+
? (state.metrics.totalTokens > 1000 ? `${(state.metrics.totalTokens / 1000).toFixed(1)}k` : state.metrics.totalTokens)
|
|
563
|
+
: '0';
|
|
564
|
+
|
|
565
|
+
document.getElementById('m-auto').textContent = state.metrics.auto_resolved;
|
|
566
|
+
document.getElementById('m-timeout').textContent = state.metrics.timeout;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Filter toggles ─────────────────────────────────────────────────
|
|
570
|
+
function renderFilterBar() {
|
|
571
|
+
const el = document.getElementById('thread-filters');
|
|
572
|
+
el.innerHTML = FILTER_CATEGORIES.map(cat =>
|
|
573
|
+
`<span class="filter-chip ${state.filters.has(cat) ? 'active' : ''}" onclick="toggleFilter('${cat}')">${FILTER_LABELS[cat]}</span>`
|
|
574
|
+
).join('');
|
|
575
|
+
}
|
|
576
|
+
function toggleFilter(cat) {
|
|
577
|
+
if (state.filters.has(cat)) state.filters.delete(cat);
|
|
578
|
+
else state.filters.add(cat);
|
|
579
|
+
saveFilters();
|
|
580
|
+
renderFilterBar();
|
|
581
|
+
renderThreads();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── Thread card rendering ──────────────────────────────────────────
|
|
585
|
+
function escapeHtml(s) {
|
|
586
|
+
return String(s ?? '').replace(/[&<>"']/g, c =>
|
|
587
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderFixedZone(t, type, profile) {
|
|
591
|
+
const created = parseDbTimestamp(t.created_at);
|
|
592
|
+
const ageMs = created ? created.getTime() : Date.now();
|
|
593
|
+
const profileBadge = profile
|
|
594
|
+
? `<span class="profile-badge ${profile}">${profile}</span>`
|
|
595
|
+
: '';
|
|
596
|
+
return `<div class="thread-zone-fixed">
|
|
597
|
+
<div class="thread-head">
|
|
598
|
+
<div class="thread-head-left">
|
|
599
|
+
<div class="thread-subject">${escapeHtml(t.subject)}</div>
|
|
600
|
+
<div class="thread-initiator">Par: ${escapeHtml(displayName(t.initiator_id))}${profileBadge}</div>
|
|
601
|
+
</div>
|
|
602
|
+
<div class="thread-head-right">
|
|
603
|
+
<span class="status-pill ${t.status}">${t.status}</span>
|
|
604
|
+
<div><span class="rel" data-ms="${ageMs}">${formatRelativeTime(ageMs)}</span></div>
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
</div>`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderContextualZone(t, type, profile) {
|
|
611
|
+
const rows = [];
|
|
612
|
+
const f = state.filters;
|
|
613
|
+
const activity = state.threadActivity[t.id];
|
|
614
|
+
|
|
615
|
+
// ── Workflow: claim status + timeout countdown ──
|
|
616
|
+
if (f.has('workflow')) {
|
|
617
|
+
if (type === 'work_stealing') {
|
|
618
|
+
if (t.claimed_by) {
|
|
619
|
+
const claimedAt = parseDbTimestamp(t.claimed_at);
|
|
620
|
+
const ms = claimedAt ? claimedAt.getTime() : Date.now();
|
|
621
|
+
rows.push(`🔧 Pris par <strong>${escapeHtml(displayName(t.claimed_by))}</strong> <span style="color:#64748b;">(il y a <span class="rel" data-ms="${ms}">${formatRelativeTime(ms)}</span>)</span>`);
|
|
622
|
+
} else {
|
|
623
|
+
rows.push(`🟢 Disponible — attend un agent`);
|
|
624
|
+
}
|
|
625
|
+
} else if (type === 'resolving') {
|
|
626
|
+
rows.push(`🔵 En résolution`);
|
|
627
|
+
if (t.resolution_summary) {
|
|
628
|
+
const preview = t.resolution_summary.slice(0, 80) + (t.resolution_summary.length > 80 ? '…' : '');
|
|
629
|
+
rows.push(`<span class="msg-preview">📝 ${escapeHtml(preview)}</span>`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (t.timeout_seconds && t.created_at) {
|
|
633
|
+
const created = parseDbTimestamp(t.created_at);
|
|
634
|
+
if (created) {
|
|
635
|
+
const deadline = created.getTime() + t.timeout_seconds * (t.round || 1) * 1000;
|
|
636
|
+
rows.push(`⏱ Timeout <span class="countdown ${countdownClass(deadline)}" data-deadline="${deadline}">${formatCountdown(deadline)}</span>`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Coordination: rounds, respondents, approvals ──
|
|
642
|
+
if (f.has('coordination')) {
|
|
643
|
+
const respondents = JSON.parse(t.expected_respondents || '[]');
|
|
644
|
+
if (type === 'consultation') {
|
|
645
|
+
rows.push(`Round ${t.round}/${t.max_rounds}`);
|
|
646
|
+
if (respondents.length) {
|
|
647
|
+
const names = respondents.map(displayName).join(', ');
|
|
648
|
+
rows.push(`⏳ En attente: ${escapeHtml(names)}`);
|
|
649
|
+
} else {
|
|
650
|
+
rows.push(`✅ Tous répondus`);
|
|
651
|
+
}
|
|
652
|
+
} else if (t.round > 1) {
|
|
653
|
+
// Non-consultation but contested → show round
|
|
654
|
+
rows.push(`<span style="color:#64748b;">Round ${t.round}/${t.max_rounds}</span>`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Impact: severity + target files ──
|
|
659
|
+
if (f.has('impact')) {
|
|
660
|
+
const sev = parseSeverity(t.subject);
|
|
661
|
+
const files = JSON.parse(t.target_files || '[]');
|
|
662
|
+
const chips = [];
|
|
663
|
+
if (sev) chips.push(`<span class="severity-pill ${sev}">${sev}</span>`);
|
|
664
|
+
if (files.length) chips.push(`📁 ${escapeHtml(files.join(', '))}`);
|
|
665
|
+
if (chips.length) rows.push(chips.join(' '));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── Activity: message count + last message preview ──
|
|
669
|
+
if (f.has('activity') && activity && activity.messageCount > 0) {
|
|
670
|
+
const preview = activity.lastMessageText
|
|
671
|
+
? `"${escapeHtml(activity.lastMessageText.slice(0, 50))}${activity.lastMessageText.length > 50 ? '…' : ''}"`
|
|
672
|
+
: '';
|
|
673
|
+
const ago = activity.lastMessageAt
|
|
674
|
+
? ` <span style="color:#64748b;">il y a <span class="rel" data-ms="${activity.lastMessageAt}">${formatRelativeTime(activity.lastMessageAt)}</span></span>`
|
|
675
|
+
: '';
|
|
676
|
+
rows.push(`💬 ${activity.messageCount} msg${preview ? ' · <span class="msg-preview">' + preview + '</span>' : ''}${ago}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── Tokens ──
|
|
680
|
+
if (f.has('tokens') && activity && activity.tokens > 0) {
|
|
681
|
+
rows.push(`🪙 ~${formatTokens(activity.tokens)} tokens`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return rows.length
|
|
685
|
+
? `<div class="thread-zone-ctx">${rows.map(r => `<div>${r}</div>`).join('')}</div>`
|
|
686
|
+
: '';
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function renderThreadCard(t) {
|
|
690
|
+
const type = detectThreadType(t);
|
|
691
|
+
const profile = getInitiatorProfile(t);
|
|
692
|
+
// Staleness: no activity for > 30s AND not resolving (resolving is expected to be quiet)
|
|
693
|
+
const activity = state.threadActivity[t.id];
|
|
694
|
+
const lastMs = activity?.lastMessageAt
|
|
695
|
+
?? (parseDbTimestamp(t.created_at)?.getTime() ?? Date.now());
|
|
696
|
+
const stale = t.status !== 'resolving' && (Date.now() - lastMs) > 30_000;
|
|
697
|
+
return `<div class="thread-card ${stale ? 'stale' : ''}" id="thread-${t.id}">
|
|
698
|
+
${renderFixedZone(t, type, profile)}
|
|
699
|
+
${renderContextualZone(t, type, profile)}
|
|
700
|
+
</div>`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function renderThreads() {
|
|
704
|
+
const threads = Object.values(state.threadsCache);
|
|
705
|
+
const el = document.getElementById('threads-list');
|
|
706
|
+
if (!threads.length) {
|
|
707
|
+
el.innerHTML = '<div style="color:#64748b;font-size:12px;">Aucune</div>';
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Sort: resolving first, then open, then by most recent activity
|
|
711
|
+
threads.sort((a, b) => {
|
|
712
|
+
const order = { resolving: 0, open: 1, resolved: 2, cancelled: 3 };
|
|
713
|
+
const oa = order[a.status] ?? 9, ob = order[b.status] ?? 9;
|
|
714
|
+
if (oa !== ob) return oa - ob;
|
|
715
|
+
const aAct = state.threadActivity[a.id]?.lastMessageAt ?? 0;
|
|
716
|
+
const bAct = state.threadActivity[b.id]?.lastMessageAt ?? 0;
|
|
717
|
+
return bAct - aAct;
|
|
718
|
+
});
|
|
719
|
+
el.innerHTML = threads.map(renderThreadCard).join('');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function flashCard(threadId) {
|
|
723
|
+
const el = document.getElementById(`thread-${threadId}`);
|
|
724
|
+
if (!el) return;
|
|
725
|
+
el.classList.remove('flash');
|
|
726
|
+
// Force reflow so the animation restarts
|
|
727
|
+
void el.offsetWidth;
|
|
728
|
+
el.classList.add('flash');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function updateThreads() {
|
|
732
|
+
fetch(`${COORDINATOR_URL}/api/threads-active`, {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
headers: { 'Content-Type': 'application/json' },
|
|
735
|
+
body: '{}',
|
|
736
|
+
})
|
|
737
|
+
.then(r => r.json())
|
|
738
|
+
.then(threads => {
|
|
739
|
+
if (!Array.isArray(threads)) return;
|
|
740
|
+
const activeIds = new Set(threads.map(t => t.id));
|
|
741
|
+
// Drop threads that are no longer active
|
|
742
|
+
for (const id of Object.keys(state.threadsCache)) {
|
|
743
|
+
if (!activeIds.has(id)) delete state.threadsCache[id];
|
|
744
|
+
}
|
|
745
|
+
// Update cache + flash on status transition
|
|
746
|
+
const flashQueue = [];
|
|
747
|
+
for (const t of threads) {
|
|
748
|
+
const prev = state.threadPrevStatus[t.id];
|
|
749
|
+
state.threadsCache[t.id] = t;
|
|
750
|
+
if (prev && prev !== t.status) flashQueue.push(t.id);
|
|
751
|
+
state.threadPrevStatus[t.id] = t.status;
|
|
752
|
+
}
|
|
753
|
+
renderThreads();
|
|
754
|
+
for (const id of flashQueue) flashCard(id);
|
|
755
|
+
})
|
|
756
|
+
.catch(() => {});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Refresh relative times in place without re-rendering the DOM ───
|
|
760
|
+
function tickRelativeTimes() {
|
|
761
|
+
document.querySelectorAll('.rel').forEach(el => {
|
|
762
|
+
const ms = Number(el.dataset.ms);
|
|
763
|
+
if (!isNaN(ms)) el.textContent = formatRelativeTime(ms);
|
|
764
|
+
});
|
|
765
|
+
document.querySelectorAll('.countdown').forEach(el => {
|
|
766
|
+
const ms = Number(el.dataset.deadline);
|
|
767
|
+
if (isNaN(ms)) return;
|
|
768
|
+
el.textContent = formatCountdown(ms);
|
|
769
|
+
el.classList.remove('urgent', 'warning');
|
|
770
|
+
const cls = countdownClass(ms);
|
|
771
|
+
if (cls) el.classList.add(cls);
|
|
772
|
+
});
|
|
773
|
+
// Refresh staleness class without full re-render
|
|
774
|
+
for (const [id, t] of Object.entries(state.threadsCache)) {
|
|
775
|
+
const card = document.getElementById(`thread-${id}`);
|
|
776
|
+
if (!card) continue;
|
|
777
|
+
const activity = state.threadActivity[id];
|
|
778
|
+
const lastMs = activity?.lastMessageAt
|
|
779
|
+
?? (parseDbTimestamp(t.created_at)?.getTime() ?? Date.now());
|
|
780
|
+
const stale = t.status !== 'resolving' && (Date.now() - lastMs) > 30_000;
|
|
781
|
+
card.classList.toggle('stale', stale);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Resizable right panel ──────────────────────────────────────────
|
|
786
|
+
(function initRightPanelResizer() {
|
|
787
|
+
const grid = document.querySelector('.grid');
|
|
788
|
+
const handle = document.getElementById('resize-right');
|
|
789
|
+
if (!grid || !handle) return;
|
|
790
|
+
|
|
791
|
+
const MIN_WIDTH = 220;
|
|
792
|
+
const MAX_WIDTH = 1000;
|
|
793
|
+
const LEFT_WIDTH = 250;
|
|
794
|
+
const HANDLE_WIDTH = 6;
|
|
795
|
+
|
|
796
|
+
function applyRightWidth(px) {
|
|
797
|
+
const clamped = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, px));
|
|
798
|
+
grid.style.gridTemplateColumns = `${LEFT_WIDTH}px 1fr ${HANDLE_WIDTH}px ${clamped}px`;
|
|
799
|
+
return clamped;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Restore persisted width on load
|
|
803
|
+
const saved = parseInt(localStorage.getItem('dashboard.rightWidth') || '', 10);
|
|
804
|
+
if (!isNaN(saved)) applyRightWidth(saved);
|
|
805
|
+
|
|
806
|
+
let dragging = false;
|
|
807
|
+
handle.addEventListener('mousedown', (e) => {
|
|
808
|
+
dragging = true;
|
|
809
|
+
handle.classList.add('dragging');
|
|
810
|
+
document.body.classList.add('resizing');
|
|
811
|
+
e.preventDefault();
|
|
812
|
+
});
|
|
813
|
+
document.addEventListener('mousemove', (e) => {
|
|
814
|
+
if (!dragging) return;
|
|
815
|
+
// New width = distance from mouse to right edge of viewport
|
|
816
|
+
const newWidth = window.innerWidth - e.clientX - HANDLE_WIDTH / 2;
|
|
817
|
+
applyRightWidth(newWidth);
|
|
818
|
+
});
|
|
819
|
+
document.addEventListener('mouseup', () => {
|
|
820
|
+
if (!dragging) return;
|
|
821
|
+
dragging = false;
|
|
822
|
+
handle.classList.remove('dragging');
|
|
823
|
+
document.body.classList.remove('resizing');
|
|
824
|
+
// Persist current width
|
|
825
|
+
const m = grid.style.gridTemplateColumns.match(/(\d+)px$/);
|
|
826
|
+
if (m) localStorage.setItem('dashboard.rightWidth', m[1]);
|
|
827
|
+
});
|
|
828
|
+
// Double-click to reset to default
|
|
829
|
+
handle.addEventListener('dblclick', () => {
|
|
830
|
+
applyRightWidth(300);
|
|
831
|
+
localStorage.setItem('dashboard.rightWidth', '300');
|
|
832
|
+
});
|
|
833
|
+
})();
|
|
834
|
+
|
|
835
|
+
// Poll threads every 2s; tick relative times every 1s
|
|
836
|
+
renderFilterBar();
|
|
837
|
+
updateThreads();
|
|
838
|
+
setInterval(updateThreads, 2000);
|
|
839
|
+
setInterval(tickRelativeTimes, 1000);
|
|
840
|
+
|
|
841
|
+
function updateAgents() {
|
|
842
|
+
const list = document.getElementById('agents-list');
|
|
843
|
+
const statusLabels = { working: '💻 Au travail', idle: '⏸ En ligne', waiting: '⏳ En consultation', offline: '🔴 Hors ligne' };
|
|
844
|
+
const statusDotClass = { working: 'working', idle: 'idle', waiting: 'waiting', offline: 'offline' };
|
|
845
|
+
list.innerHTML = Object.values(state.agents).map(a => {
|
|
846
|
+
const actStatus = a.activity_status || 'idle';
|
|
847
|
+
const dotClass = statusDotClass[actStatus] || 'online';
|
|
848
|
+
const label = statusLabels[actStatus] || 'En ligne';
|
|
849
|
+
const fileInfo = a.current_file ? `<div class="agent-activity">📄 ${a.current_file}</div>` : '';
|
|
850
|
+
const threadInfo = a.current_thread ? `<div class="agent-activity">🔗 Thread ${a.current_thread.slice(0, 8)}</div>` : '';
|
|
851
|
+
return `<div class="agent-card">
|
|
852
|
+
<span class="status-dot ${dotClass}"></span>
|
|
853
|
+
<span class="agent-name">${a.name || a.agent_id}</span>
|
|
854
|
+
<span style="font-size:10px;color:#94a3b8;margin-left:8px;">${label}</span>
|
|
855
|
+
<div class="agent-modules">${(a.modules || []).join(', ')}</div>
|
|
856
|
+
${fileInfo}${threadInfo}
|
|
857
|
+
</div>`;
|
|
858
|
+
}).join('') || '<div style="color:#64748b;font-size:12px;">Aucun agent</div>';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// SSE Connection
|
|
862
|
+
function connectSSE() {
|
|
863
|
+
const es = new EventSource(`${COORDINATOR_URL}/api/events`);
|
|
864
|
+
const dot = document.getElementById('conn-dot');
|
|
865
|
+
const status = document.getElementById('conn-status');
|
|
866
|
+
|
|
867
|
+
es.onopen = () => { dot.className = 'status-dot online'; status.innerHTML = '<span class="status-dot online" id="conn-dot"></span> Connecté'; };
|
|
868
|
+
es.onerror = () => { dot.className = 'status-dot offline'; status.innerHTML = '<span class="status-dot offline" id="conn-dot"></span> Déconnecté'; };
|
|
869
|
+
|
|
870
|
+
const eventTypes = ['agent_online','agent_offline','agent_activity','thread_opened','message_posted','resolution_proposed','thread_resolved','thread_cancelled','file_edited','action_summary','impact_scored','introspection_requested','introspection_completed','run_config','token_usage','quota_update'];
|
|
871
|
+
for (const type of eventTypes) {
|
|
872
|
+
es.addEventListener(type, (e) => addEvent(type, e.data, e.lastEventId));
|
|
873
|
+
}
|
|
874
|
+
// Quota is pushed via SSE on refresh; wire it to the widget directly.
|
|
875
|
+
es.addEventListener('quota_update', (e) => {
|
|
876
|
+
try { renderQuotaWidget(JSON.parse(e.data)); } catch { /* ignore malformed payload */ }
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function updateHotFiles() {
|
|
881
|
+
fetch(`${COORDINATOR_URL}/api/hot-files`, {
|
|
882
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
883
|
+
body: JSON.stringify({ since_minutes: 60 })
|
|
884
|
+
})
|
|
885
|
+
.then(r => r.json())
|
|
886
|
+
.then(files => {
|
|
887
|
+
const el = document.getElementById('hot-files');
|
|
888
|
+
el.innerHTML = files.map(f =>
|
|
889
|
+
`<div class="hot-file">${f.file_path} <span style="float:right;color:#8b8fa3;font-size:11px;">${f.agent_count} agents</span></div>`
|
|
890
|
+
).join('') || '<div style="color:#64748b;font-size:12px;">Aucun</div>';
|
|
891
|
+
})
|
|
892
|
+
.catch(() => {});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Poll hot files every 5s
|
|
896
|
+
updateHotFiles();
|
|
897
|
+
setInterval(updateHotFiles, 5000);
|
|
898
|
+
|
|
899
|
+
// Quota widget — lazy first fetch, then SSE pushes refresh.
|
|
900
|
+
function bucketClass(pct) {
|
|
901
|
+
if (pct >= 90) return 'danger';
|
|
902
|
+
if (pct >= 70) return 'warn';
|
|
903
|
+
return '';
|
|
904
|
+
}
|
|
905
|
+
function formatReset(mins) {
|
|
906
|
+
if (mins <= 0) return 'reset imminent';
|
|
907
|
+
if (mins < 60) return `reset ${mins}min`;
|
|
908
|
+
const h = Math.floor(mins / 60);
|
|
909
|
+
const m = mins % 60;
|
|
910
|
+
return `reset ${h}h${m ? ' ' + m + 'min' : ''}`;
|
|
911
|
+
}
|
|
912
|
+
function renderQuotaWidget(data) {
|
|
913
|
+
const el = document.getElementById('quota-widget');
|
|
914
|
+
if (!el) return;
|
|
915
|
+
if (!data || !data.five_hour || !data.seven_day) {
|
|
916
|
+
el.innerHTML = '<div class="quota-unavailable">Quota indisponible</div>';
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const buckets = [
|
|
920
|
+
{ label: '5h', b: data.five_hour },
|
|
921
|
+
{ label: '7j', b: data.seven_day },
|
|
922
|
+
];
|
|
923
|
+
if (data.seven_day_sonnet) buckets.push({ label: '7j Sonnet', b: data.seven_day_sonnet });
|
|
924
|
+
el.innerHTML = `<div class="quota-panel">${buckets.map(({label, b}) => {
|
|
925
|
+
const pct = b.utilization || 0;
|
|
926
|
+
const cls = bucketClass(pct);
|
|
927
|
+
const mins = b.minutesUntilReset ?? 0;
|
|
928
|
+
const barWidth = Math.min(100, Math.max(0, pct));
|
|
929
|
+
return `<div class="quota-bucket ${cls}">
|
|
930
|
+
<div class="quota-label">${label}</div>
|
|
931
|
+
<div class="quota-value">${pct.toFixed(1)}%</div>
|
|
932
|
+
<div class="quota-reset">${formatReset(mins)}</div>
|
|
933
|
+
<div class="quota-bar"><div class="quota-bar-fill ${cls}" style="width:${barWidth}%"></div></div>
|
|
934
|
+
</div>`;
|
|
935
|
+
}).join('')}</div>`;
|
|
936
|
+
}
|
|
937
|
+
function render503(body) {
|
|
938
|
+
const reason = body?.reason || 'raison inconnue';
|
|
939
|
+
let extra = '';
|
|
940
|
+
if (body?.cooldown_until) {
|
|
941
|
+
const mins = Math.max(0, Math.ceil((body.cooldown_until - Date.now()) / 60000));
|
|
942
|
+
extra = ` — cool-down ${mins}min avant retry`;
|
|
943
|
+
}
|
|
944
|
+
document.getElementById('quota-widget').innerHTML =
|
|
945
|
+
`<div class="quota-unavailable">Quota indisponible : ${reason}${extra}</div>`;
|
|
946
|
+
}
|
|
947
|
+
function fetchQuotaOnce() {
|
|
948
|
+
fetch(`${COORDINATOR_URL}/api/quota`)
|
|
949
|
+
.then(async r => {
|
|
950
|
+
if (r.status === 503) { render503(await r.json().catch(() => null)); return null; }
|
|
951
|
+
return r.ok ? r.json() : null;
|
|
952
|
+
})
|
|
953
|
+
.then(data => { if (data) renderQuotaWidget(data); })
|
|
954
|
+
.catch(() => { /* silent */ });
|
|
955
|
+
}
|
|
956
|
+
fetchQuotaOnce();
|
|
957
|
+
|
|
958
|
+
// One-shot version fetch — the server exposes it via /health so we don't
|
|
959
|
+
// need a dedicated endpoint. Silent on failure (just leaves the placeholder).
|
|
960
|
+
fetch(`${COORDINATOR_URL}/health`)
|
|
961
|
+
.then(r => r.ok ? r.json() : null)
|
|
962
|
+
.then(data => {
|
|
963
|
+
const el = document.getElementById('server-version');
|
|
964
|
+
if (el && data?.version) el.textContent = `v${data.version}`;
|
|
965
|
+
if (data?.version) document.title = `MCP Coordinator v${data.version} — Dashboard`;
|
|
966
|
+
})
|
|
967
|
+
.catch(() => { /* offline — header stays muted */ });
|
|
968
|
+
|
|
969
|
+
// Force-refresh button → POST /api/quota/refresh. The server bypasses the
|
|
970
|
+
// cache TTL and also broadcasts the new value via SSE, so we don't need
|
|
971
|
+
// to re-render locally — the onmessage listener handles it. Disables the
|
|
972
|
+
// button during the in-flight call so impatient clicks don't stack.
|
|
973
|
+
function refreshQuota() {
|
|
974
|
+
const btn = document.getElementById('quota-refresh-btn');
|
|
975
|
+
if (!btn || btn.disabled) return;
|
|
976
|
+
btn.disabled = true;
|
|
977
|
+
const originalText = btn.textContent;
|
|
978
|
+
btn.textContent = '⟳ …';
|
|
979
|
+
btn.style.opacity = '0.6';
|
|
980
|
+
|
|
981
|
+
fetch(`${COORDINATOR_URL}/api/quota/refresh`, { method: 'POST' })
|
|
982
|
+
.then(async r => {
|
|
983
|
+
if (r.status === 503) { render503(await r.json().catch(() => null)); return null; }
|
|
984
|
+
return r.ok ? r.json() : null;
|
|
985
|
+
})
|
|
986
|
+
.then(data => { if (data) renderQuotaWidget(data); })
|
|
987
|
+
.catch(() => { /* silent — button still re-enables */ })
|
|
988
|
+
.finally(() => {
|
|
989
|
+
btn.disabled = false;
|
|
990
|
+
btn.textContent = originalText;
|
|
991
|
+
btn.style.opacity = '1';
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function clearTimeline() {
|
|
996
|
+
document.getElementById('events').innerHTML = '';
|
|
997
|
+
state.metrics = { threads: 0, conflicts: 0, resolved: 0, totalTime: 0, totalTokens: 0, auto_resolved: 0, timeout: 0, consensus: 0 };
|
|
998
|
+
state.agents = {};
|
|
999
|
+
state.seenIds.clear();
|
|
1000
|
+
state.tokens = {};
|
|
1001
|
+
updateMetrics();
|
|
1002
|
+
updateAgents();
|
|
1003
|
+
renderTokenBudget();
|
|
1004
|
+
// Reset server events
|
|
1005
|
+
fetch(`${COORDINATOR_URL}/api/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ── Token usage tracking + rendering ──────────────────────────────
|
|
1009
|
+
function trackTokenUsage(p) {
|
|
1010
|
+
const agentId = p.agent_id;
|
|
1011
|
+
if (!agentId) return;
|
|
1012
|
+
const agent = state.tokens[agentId] ||= {
|
|
1013
|
+
name: p.agent_name || agentId,
|
|
1014
|
+
totalCost: 0, totalInput: 0, totalOutput: 0, cacheRead: 0, cacheCreation: 0, turns: 0,
|
|
1015
|
+
byPhase: {},
|
|
1016
|
+
byModel: {},
|
|
1017
|
+
};
|
|
1018
|
+
agent.name = p.agent_name || agent.name;
|
|
1019
|
+
agent.totalCost += Number(p.cost_usd) || 0;
|
|
1020
|
+
agent.totalInput += Number(p.input_tokens) || 0;
|
|
1021
|
+
agent.totalOutput += Number(p.output_tokens) || 0;
|
|
1022
|
+
agent.cacheRead += Number(p.cache_read_tokens) || 0;
|
|
1023
|
+
agent.cacheCreation += Number(p.cache_creation_tokens) || 0;
|
|
1024
|
+
agent.turns += 1;
|
|
1025
|
+
|
|
1026
|
+
const phase = p.phase || 'unknown';
|
|
1027
|
+
const pb = agent.byPhase[phase] ||= { cost: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0, turns: 0 };
|
|
1028
|
+
pb.cost += Number(p.cost_usd) || 0;
|
|
1029
|
+
pb.input += Number(p.input_tokens) || 0;
|
|
1030
|
+
pb.output += Number(p.output_tokens) || 0;
|
|
1031
|
+
pb.cacheRead += Number(p.cache_read_tokens) || 0;
|
|
1032
|
+
pb.cacheCreation += Number(p.cache_creation_tokens) || 0;
|
|
1033
|
+
pb.turns += 1;
|
|
1034
|
+
|
|
1035
|
+
const model = (p.model || 'unknown').toString();
|
|
1036
|
+
agent.byModel[model] = (agent.byModel[model] || 0) + (Number(p.cost_usd) || 0);
|
|
1037
|
+
|
|
1038
|
+
renderTokenBudget();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function fmtTokens(n) {
|
|
1042
|
+
if (!n) return '0';
|
|
1043
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1044
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
1045
|
+
return String(n);
|
|
1046
|
+
}
|
|
1047
|
+
function fmtCost(n) {
|
|
1048
|
+
if (!n) return '$0.00';
|
|
1049
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
1050
|
+
return `$${n.toFixed(2)}`;
|
|
1051
|
+
}
|
|
1052
|
+
function cacheClass(pct) {
|
|
1053
|
+
if (pct >= 70) return 'cache-good';
|
|
1054
|
+
if (pct >= 40) return 'cache-meh';
|
|
1055
|
+
return 'cache-bad';
|
|
1056
|
+
}
|
|
1057
|
+
function modelShort(m) {
|
|
1058
|
+
if (!m) return '?';
|
|
1059
|
+
if (m.includes('haiku')) return 'haiku';
|
|
1060
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
1061
|
+
if (m.includes('opus')) return 'opus';
|
|
1062
|
+
return m.slice(0, 12);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function renderTokenBudget() {
|
|
1066
|
+
const agents = Object.entries(state.tokens);
|
|
1067
|
+
const totalEl = document.getElementById('token-total');
|
|
1068
|
+
const agentsEl = document.getElementById('token-agents');
|
|
1069
|
+
|
|
1070
|
+
if (agents.length === 0) {
|
|
1071
|
+
totalEl.innerHTML = '<div style="color:#64748b;font-size:12px;">Pas encore de turns LLM</div>';
|
|
1072
|
+
agentsEl.innerHTML = '';
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Global totals
|
|
1077
|
+
const sum = agents.reduce((acc, [, a]) => {
|
|
1078
|
+
acc.cost += a.totalCost;
|
|
1079
|
+
acc.input += a.totalInput;
|
|
1080
|
+
acc.output += a.totalOutput;
|
|
1081
|
+
acc.cacheRead += a.cacheRead;
|
|
1082
|
+
acc.cacheCreation += a.cacheCreation;
|
|
1083
|
+
acc.turns += a.turns;
|
|
1084
|
+
return acc;
|
|
1085
|
+
}, { cost: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0, turns: 0 });
|
|
1086
|
+
const totalInputAll = sum.input + sum.cacheRead + sum.cacheCreation;
|
|
1087
|
+
const hitPct = totalInputAll > 0 ? Math.round((sum.cacheRead / totalInputAll) * 100) : 0;
|
|
1088
|
+
|
|
1089
|
+
// Bar segments by input token source
|
|
1090
|
+
const barTotal = Math.max(1, sum.input + sum.output + sum.cacheRead + sum.cacheCreation);
|
|
1091
|
+
const pct = (n) => (n / barTotal) * 100;
|
|
1092
|
+
|
|
1093
|
+
totalEl.innerHTML = `
|
|
1094
|
+
<div class="token-total">
|
|
1095
|
+
<div class="token-total-row big">
|
|
1096
|
+
<span>Total</span><span>${fmtCost(sum.cost)}</span>
|
|
1097
|
+
</div>
|
|
1098
|
+
<div class="token-total-row"><span>Turns</span><span>${sum.turns}</span></div>
|
|
1099
|
+
<div class="token-total-row"><span>Input (fresh)</span><span>${fmtTokens(sum.input)}</span></div>
|
|
1100
|
+
<div class="token-total-row"><span>Output</span><span>${fmtTokens(sum.output)}</span></div>
|
|
1101
|
+
<div class="token-total-row"><span>Cache read</span><span>${fmtTokens(sum.cacheRead)}</span></div>
|
|
1102
|
+
<div class="token-total-row"><span>Cache write</span><span>${fmtTokens(sum.cacheCreation)}</span></div>
|
|
1103
|
+
<div class="token-total-row"><span>Cache hit</span><span class="${cacheClass(hitPct)}">${hitPct}%</span></div>
|
|
1104
|
+
<div class="token-bar">
|
|
1105
|
+
<div class="token-bar-input" style="width:${pct(sum.input)}%" title="fresh input"></div>
|
|
1106
|
+
<div class="token-bar-cache-r" style="width:${pct(sum.cacheRead)}%" title="cache read"></div>
|
|
1107
|
+
<div class="token-bar-cache-w" style="width:${pct(sum.cacheCreation)}%" title="cache write"></div>
|
|
1108
|
+
<div class="token-bar-output" style="width:${pct(sum.output)}%" title="output"></div>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
`;
|
|
1112
|
+
|
|
1113
|
+
// Sort agents by cost desc
|
|
1114
|
+
agents.sort(([, a], [, b]) => b.totalCost - a.totalCost);
|
|
1115
|
+
agentsEl.innerHTML = agents.map(([id, a]) => {
|
|
1116
|
+
const totalIn = a.totalInput + a.cacheRead + a.cacheCreation;
|
|
1117
|
+
const agentHit = totalIn > 0 ? Math.round((a.cacheRead / totalIn) * 100) : 0;
|
|
1118
|
+
const phases = Object.entries(a.byPhase).sort(([, x], [, y]) => y.cost - x.cost);
|
|
1119
|
+
const phaseChips = phases.map(([ph, p]) =>
|
|
1120
|
+
`<span class="token-phase-chip">${ph}<span class="cost">${fmtCost(p.cost)}</span></span>`
|
|
1121
|
+
).join('');
|
|
1122
|
+
const models = Object.entries(a.byModel).sort(([, x], [, y]) => y - x);
|
|
1123
|
+
const modelChips = models.map(([m, c]) =>
|
|
1124
|
+
`<span class="token-phase-chip" style="background:#2a1a3a;">${modelShort(m)}<span class="cost">${fmtCost(c)}</span></span>`
|
|
1125
|
+
).join('');
|
|
1126
|
+
return `
|
|
1127
|
+
<div class="token-agent">
|
|
1128
|
+
<div class="token-agent-name">${a.name} <span style="color:#94a3b8;font-weight:400;font-size:10px;">${fmtCost(a.totalCost)} · ${a.turns} turns</span></div>
|
|
1129
|
+
<div class="token-agent-row">
|
|
1130
|
+
<span>in ${fmtTokens(a.totalInput)} · out ${fmtTokens(a.totalOutput)}</span>
|
|
1131
|
+
<span class="${cacheClass(agentHit)}">cache ${agentHit}%</span>
|
|
1132
|
+
</div>
|
|
1133
|
+
<div style="margin-top:4px;">${phaseChips}</div>
|
|
1134
|
+
<div style="margin-top:2px;">${modelChips}</div>
|
|
1135
|
+
</div>
|
|
1136
|
+
`;
|
|
1137
|
+
}).join('');
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Initial render — empty state
|
|
1141
|
+
renderTokenBudget();
|
|
1142
|
+
|
|
1143
|
+
function renderRunConfig(config) {
|
|
1144
|
+
const el = document.getElementById('run-config');
|
|
1145
|
+
if (!config || !config.name) {
|
|
1146
|
+
el.innerHTML = '<div style="color:#64748b;font-size:12px;">Aucun run actif</div>';
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const profiles = (config.agents || []).reduce((acc, a) => {
|
|
1150
|
+
acc[a.profile] = (acc[a.profile] || 0) + 1;
|
|
1151
|
+
return acc;
|
|
1152
|
+
}, {});
|
|
1153
|
+
const profileStr = Object.entries(profiles).map(([k, v]) => `${v} ${k}`).join(', ');
|
|
1154
|
+
const staggerStr = config.stagger ? `${config.stagger.mode}${config.stagger.delay ? ` (${config.stagger.delay[0]}-${config.stagger.delay[1]}s)` : ''}` : '-';
|
|
1155
|
+
|
|
1156
|
+
el.innerHTML = `<div class="config-section">
|
|
1157
|
+
<div class="config-title">${config.name}</div>
|
|
1158
|
+
<div style="font-size:10px;color:#94a3b8;margin-bottom:8px;">${config.description || ''}</div>
|
|
1159
|
+
<div class="config-row"><span class="config-key">Phase</span><span class="config-val">${config.phase || '-'}</span></div>
|
|
1160
|
+
<div class="config-row"><span class="config-key">Agents</span><span class="config-val">${(config.agents || []).length} (${profileStr})</span></div>
|
|
1161
|
+
<div class="config-row"><span class="config-key">Workspace</span><span class="config-val">${config.workspace?.type || '-'}</span></div>
|
|
1162
|
+
<div class="config-row"><span class="config-key">Stagger</span><span class="config-val">${staggerStr}</span></div>
|
|
1163
|
+
<div class="config-row"><span class="config-key">Timeout</span><span class="config-val">${config.timeout_minutes || 15} min</span></div>
|
|
1164
|
+
<div class="config-row"><span class="config-key">Compare</span><span class="config-val">${config.compare_mode ? 'Oui' : 'Non'}</span></div>
|
|
1165
|
+
<div class="config-agents">${(config.agents || []).map(a =>
|
|
1166
|
+
`<span class="config-agent-tag">${a.name} <span style="color:#64748b;">(${a.profile})</span></span>`
|
|
1167
|
+
).join('')}</div>
|
|
1168
|
+
</div>`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Fetch config on load
|
|
1172
|
+
fetch(`${COORDINATOR_URL}/api/run-config`).then(r => r.json()).then(renderRunConfig).catch(() => {});
|
|
1173
|
+
|
|
1174
|
+
connectSSE();
|
|
1175
|
+
updateAgents();
|
|
1176
|
+
</script>
|
|
1177
|
+
</body>
|
|
1178
|
+
</html>
|