@jhizzard/termdeck 0.10.4 → 0.12.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/package.json +3 -2
- package/packages/cli/src/init-rumen.js +153 -83
- package/packages/client/public/app.js +207 -4
- package/packages/client/public/flashback-history.html +331 -0
- package/packages/client/public/flashback-history.js +258 -0
- package/packages/client/public/graph-controls.js +217 -0
- package/packages/client/public/graph.html +36 -0
- package/packages/client/public/graph.js +131 -15
- package/packages/client/public/index.html +25 -0
- package/packages/client/public/style.css +230 -0
- package/packages/server/src/config.js +49 -0
- package/packages/server/src/database.js +49 -1
- package/packages/server/src/flashback-diag.js +187 -13
- package/packages/server/src/index.js +132 -19
- package/packages/server/src/projects-routes.js +119 -0
- package/packages/server/src/pty-reaper.js +297 -0
- package/packages/server/src/setup/index.js +1 -0
- package/packages/server/src/setup/migration-templating.js +76 -0
- package/packages/server/src/setup/migrations.js +44 -4
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +381 -0
- package/packages/server/src/setup/rumen/functions/graph-inference/tsconfig.json +14 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>TermDeck · Flashback History</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
<style>
|
|
9
|
+
/* Sprint 43 T2 — flashback-history page styling. Self-contained so the
|
|
10
|
+
dashboard ships independently of style.css; uses the existing --tg-*
|
|
11
|
+
design tokens for theme consistency. */
|
|
12
|
+
body.fb-page {
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: var(--tg-bg);
|
|
15
|
+
color: var(--tg-text);
|
|
16
|
+
font-family: var(--tg-sans);
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
}
|
|
19
|
+
.fb-topbar {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 16px;
|
|
23
|
+
padding: 8px 16px;
|
|
24
|
+
background: var(--tg-surface);
|
|
25
|
+
border-bottom: 1px solid var(--tg-border);
|
|
26
|
+
min-height: 44px;
|
|
27
|
+
}
|
|
28
|
+
.fb-tb-back {
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 6px;
|
|
32
|
+
color: var(--tg-text-bright);
|
|
33
|
+
text-decoration: none;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
padding: 4px 8px;
|
|
36
|
+
border-radius: var(--tg-radius-sm);
|
|
37
|
+
}
|
|
38
|
+
.fb-tb-back:hover { background: var(--tg-surface-hover); }
|
|
39
|
+
.fb-tb-divider { color: var(--tg-text-dim); }
|
|
40
|
+
.fb-tb-title { color: var(--tg-text); font-weight: 500; }
|
|
41
|
+
.fb-tb-spacer { flex: 1; }
|
|
42
|
+
.fb-tb-controls {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 6px;
|
|
46
|
+
}
|
|
47
|
+
.fb-tb-controls label {
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
color: var(--tg-text-dim);
|
|
50
|
+
}
|
|
51
|
+
.fb-tb-controls select {
|
|
52
|
+
background: var(--tg-bg);
|
|
53
|
+
color: var(--tg-text);
|
|
54
|
+
border: 1px solid var(--tg-border);
|
|
55
|
+
border-radius: var(--tg-radius-sm);
|
|
56
|
+
padding: 5px 8px;
|
|
57
|
+
font-family: var(--tg-mono);
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
}
|
|
60
|
+
.fb-tb-controls select:focus {
|
|
61
|
+
outline: none;
|
|
62
|
+
border-color: var(--tg-accent);
|
|
63
|
+
}
|
|
64
|
+
.fb-tb-btn {
|
|
65
|
+
background: var(--tg-bg);
|
|
66
|
+
color: var(--tg-text);
|
|
67
|
+
border: 1px solid var(--tg-border);
|
|
68
|
+
border-radius: var(--tg-radius-sm);
|
|
69
|
+
padding: 5px 10px;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
font-family: var(--tg-mono);
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
}
|
|
74
|
+
.fb-tb-btn:hover {
|
|
75
|
+
background: var(--tg-surface-hover);
|
|
76
|
+
border-color: var(--tg-border-active);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.fb-stage {
|
|
80
|
+
max-width: 1200px;
|
|
81
|
+
margin: 24px auto;
|
|
82
|
+
padding: 0 16px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Funnel summary card */
|
|
86
|
+
.fb-funnel {
|
|
87
|
+
background: var(--tg-surface);
|
|
88
|
+
border: 1px solid var(--tg-border);
|
|
89
|
+
border-radius: var(--tg-radius);
|
|
90
|
+
padding: 18px 20px;
|
|
91
|
+
margin-bottom: 20px;
|
|
92
|
+
}
|
|
93
|
+
.fb-funnel-title {
|
|
94
|
+
font-size: 12px;
|
|
95
|
+
color: var(--tg-text-dim);
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
letter-spacing: 0.06em;
|
|
98
|
+
margin: 0 0 12px 0;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
}
|
|
101
|
+
.fb-funnel-bars {
|
|
102
|
+
display: grid;
|
|
103
|
+
grid-template-columns: 1fr;
|
|
104
|
+
gap: 10px;
|
|
105
|
+
}
|
|
106
|
+
.fb-funnel-row {
|
|
107
|
+
display: grid;
|
|
108
|
+
grid-template-columns: 140px 1fr 80px;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 12px;
|
|
111
|
+
}
|
|
112
|
+
.fb-funnel-label {
|
|
113
|
+
font-size: 13px;
|
|
114
|
+
color: var(--tg-text);
|
|
115
|
+
}
|
|
116
|
+
.fb-funnel-bar {
|
|
117
|
+
position: relative;
|
|
118
|
+
height: 14px;
|
|
119
|
+
background: var(--tg-bg);
|
|
120
|
+
border: 1px solid var(--tg-border);
|
|
121
|
+
border-radius: 7px;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
.fb-funnel-bar-fill {
|
|
125
|
+
position: absolute;
|
|
126
|
+
inset: 0;
|
|
127
|
+
width: 0%;
|
|
128
|
+
transition: width 220ms ease-out;
|
|
129
|
+
}
|
|
130
|
+
.fb-funnel-row[data-tier="fires"] .fb-funnel-bar-fill { background: var(--tg-purple); }
|
|
131
|
+
.fb-funnel-row[data-tier="dismissed"] .fb-funnel-bar-fill { background: var(--tg-amber); }
|
|
132
|
+
.fb-funnel-row[data-tier="clicked"] .fb-funnel-bar-fill { background: var(--tg-green); }
|
|
133
|
+
.fb-funnel-count {
|
|
134
|
+
font-family: var(--tg-mono);
|
|
135
|
+
font-size: 12px;
|
|
136
|
+
color: var(--tg-text-bright);
|
|
137
|
+
text-align: right;
|
|
138
|
+
}
|
|
139
|
+
.fb-funnel-pct {
|
|
140
|
+
font-size: 10px;
|
|
141
|
+
color: var(--tg-text-dim);
|
|
142
|
+
margin-left: 4px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Zero-state */
|
|
146
|
+
.fb-zero {
|
|
147
|
+
background: var(--tg-surface);
|
|
148
|
+
border: 1px dashed var(--tg-border-active);
|
|
149
|
+
border-radius: var(--tg-radius);
|
|
150
|
+
padding: 28px 24px;
|
|
151
|
+
text-align: center;
|
|
152
|
+
color: var(--tg-text-dim);
|
|
153
|
+
}
|
|
154
|
+
.fb-zero h3 {
|
|
155
|
+
margin: 0 0 8px 0;
|
|
156
|
+
color: var(--tg-text-bright);
|
|
157
|
+
font-size: 15px;
|
|
158
|
+
}
|
|
159
|
+
.fb-zero p {
|
|
160
|
+
margin: 6px 0;
|
|
161
|
+
max-width: 620px;
|
|
162
|
+
margin-left: auto;
|
|
163
|
+
margin-right: auto;
|
|
164
|
+
line-height: 1.5;
|
|
165
|
+
}
|
|
166
|
+
.fb-zero code {
|
|
167
|
+
font-family: var(--tg-mono);
|
|
168
|
+
background: var(--tg-bg);
|
|
169
|
+
padding: 1px 6px;
|
|
170
|
+
border-radius: 3px;
|
|
171
|
+
color: var(--tg-cyan);
|
|
172
|
+
font-size: 12px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Table */
|
|
176
|
+
.fb-table-wrap {
|
|
177
|
+
background: var(--tg-surface);
|
|
178
|
+
border: 1px solid var(--tg-border);
|
|
179
|
+
border-radius: var(--tg-radius);
|
|
180
|
+
overflow: hidden;
|
|
181
|
+
}
|
|
182
|
+
.fb-table {
|
|
183
|
+
width: 100%;
|
|
184
|
+
border-collapse: collapse;
|
|
185
|
+
font-size: 12px;
|
|
186
|
+
}
|
|
187
|
+
.fb-table thead {
|
|
188
|
+
background: var(--tg-bg);
|
|
189
|
+
border-bottom: 1px solid var(--tg-border);
|
|
190
|
+
}
|
|
191
|
+
.fb-table th {
|
|
192
|
+
text-align: left;
|
|
193
|
+
padding: 10px 12px;
|
|
194
|
+
font-size: 11px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: var(--tg-text-dim);
|
|
197
|
+
text-transform: uppercase;
|
|
198
|
+
letter-spacing: 0.05em;
|
|
199
|
+
}
|
|
200
|
+
.fb-table td {
|
|
201
|
+
padding: 10px 12px;
|
|
202
|
+
border-top: 1px solid var(--tg-border);
|
|
203
|
+
vertical-align: top;
|
|
204
|
+
color: var(--tg-text);
|
|
205
|
+
}
|
|
206
|
+
.fb-table tbody tr:hover {
|
|
207
|
+
background: var(--tg-surface-hover);
|
|
208
|
+
}
|
|
209
|
+
.fb-cell-time {
|
|
210
|
+
font-family: var(--tg-mono);
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
color: var(--tg-text-dim);
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
.fb-cell-project {
|
|
216
|
+
font-family: var(--tg-mono);
|
|
217
|
+
font-size: 11px;
|
|
218
|
+
color: var(--tg-cyan);
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
}
|
|
221
|
+
.fb-cell-error {
|
|
222
|
+
font-family: var(--tg-mono);
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
color: var(--tg-text);
|
|
225
|
+
max-width: 480px;
|
|
226
|
+
overflow: hidden;
|
|
227
|
+
text-overflow: ellipsis;
|
|
228
|
+
white-space: nowrap;
|
|
229
|
+
}
|
|
230
|
+
.fb-cell-hits {
|
|
231
|
+
font-family: var(--tg-mono);
|
|
232
|
+
color: var(--tg-text-bright);
|
|
233
|
+
text-align: right;
|
|
234
|
+
}
|
|
235
|
+
.fb-cell-score {
|
|
236
|
+
font-family: var(--tg-mono);
|
|
237
|
+
color: var(--tg-text-dim);
|
|
238
|
+
text-align: right;
|
|
239
|
+
font-size: 11px;
|
|
240
|
+
}
|
|
241
|
+
.fb-cell-status {
|
|
242
|
+
white-space: nowrap;
|
|
243
|
+
}
|
|
244
|
+
.fb-pill {
|
|
245
|
+
display: inline-block;
|
|
246
|
+
padding: 2px 7px;
|
|
247
|
+
font-size: 10px;
|
|
248
|
+
font-family: var(--tg-mono);
|
|
249
|
+
border-radius: 999px;
|
|
250
|
+
border: 1px solid var(--tg-border);
|
|
251
|
+
background: var(--tg-bg);
|
|
252
|
+
color: var(--tg-text-dim);
|
|
253
|
+
margin-right: 4px;
|
|
254
|
+
}
|
|
255
|
+
.fb-pill-clicked { color: var(--tg-green); border-color: var(--tg-green); }
|
|
256
|
+
.fb-pill-dismissed { color: var(--tg-amber); border-color: var(--tg-amber); }
|
|
257
|
+
.fb-pill-pending { color: var(--tg-purple); border-color: var(--tg-purple); }
|
|
258
|
+
|
|
259
|
+
.fb-loading {
|
|
260
|
+
text-align: center;
|
|
261
|
+
padding: 40px;
|
|
262
|
+
color: var(--tg-text-dim);
|
|
263
|
+
font-size: 13px;
|
|
264
|
+
}
|
|
265
|
+
.fb-error-banner {
|
|
266
|
+
background: rgba(247, 118, 142, 0.08);
|
|
267
|
+
border: 1px solid var(--tg-red);
|
|
268
|
+
color: var(--tg-red);
|
|
269
|
+
padding: 10px 14px;
|
|
270
|
+
border-radius: var(--tg-radius-sm);
|
|
271
|
+
margin-bottom: 16px;
|
|
272
|
+
font-size: 13px;
|
|
273
|
+
}
|
|
274
|
+
</style>
|
|
275
|
+
</head>
|
|
276
|
+
<body class="fb-page">
|
|
277
|
+
|
|
278
|
+
<header class="fb-topbar">
|
|
279
|
+
<a class="fb-tb-back" href="/" title="Back to dashboard" aria-label="Back to dashboard">
|
|
280
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
281
|
+
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
282
|
+
</svg>
|
|
283
|
+
TermDeck
|
|
284
|
+
</a>
|
|
285
|
+
<span class="fb-tb-divider">/</span>
|
|
286
|
+
<span class="fb-tb-title">Flashback History</span>
|
|
287
|
+
<span class="fb-tb-spacer"></span>
|
|
288
|
+
<div class="fb-tb-controls">
|
|
289
|
+
<label for="fbWindow">Window</label>
|
|
290
|
+
<select id="fbWindow">
|
|
291
|
+
<option value="1d">last 24 h</option>
|
|
292
|
+
<option value="7d" selected>last 7 days</option>
|
|
293
|
+
<option value="30d">last 30 days</option>
|
|
294
|
+
<option value="all">all time</option>
|
|
295
|
+
</select>
|
|
296
|
+
<button type="button" class="fb-tb-btn" id="fbRefresh" title="Reload data">refresh</button>
|
|
297
|
+
</div>
|
|
298
|
+
</header>
|
|
299
|
+
|
|
300
|
+
<main class="fb-stage">
|
|
301
|
+
<div id="fbErrorBanner" class="fb-error-banner" hidden></div>
|
|
302
|
+
|
|
303
|
+
<section class="fb-funnel" id="fbFunnel" aria-label="Click-through funnel">
|
|
304
|
+
<h3 class="fb-funnel-title">Click-through funnel</h3>
|
|
305
|
+
<div class="fb-funnel-bars">
|
|
306
|
+
<div class="fb-funnel-row" data-tier="fires">
|
|
307
|
+
<span class="fb-funnel-label">Fires</span>
|
|
308
|
+
<div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarFires"></div></div>
|
|
309
|
+
<span class="fb-funnel-count"><span id="fbCountFires">—</span></span>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="fb-funnel-row" data-tier="dismissed">
|
|
312
|
+
<span class="fb-funnel-label">Dismissed</span>
|
|
313
|
+
<div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarDismissed"></div></div>
|
|
314
|
+
<span class="fb-funnel-count"><span id="fbCountDismissed">—</span><span class="fb-funnel-pct" id="fbPctDismissed"></span></span>
|
|
315
|
+
</div>
|
|
316
|
+
<div class="fb-funnel-row" data-tier="clicked">
|
|
317
|
+
<span class="fb-funnel-label">Clicked through</span>
|
|
318
|
+
<div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarClicked"></div></div>
|
|
319
|
+
<span class="fb-funnel-count"><span id="fbCountClicked">—</span><span class="fb-funnel-pct" id="fbPctClicked"></span></span>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</section>
|
|
323
|
+
|
|
324
|
+
<div id="fbContent">
|
|
325
|
+
<div class="fb-loading">Loading flashback history…</div>
|
|
326
|
+
</div>
|
|
327
|
+
</main>
|
|
328
|
+
|
|
329
|
+
<script src="flashback-history.js"></script>
|
|
330
|
+
</body>
|
|
331
|
+
</html>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// flashback-history.js — Sprint 43 T2 audit dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Renders the durable flashback_events table (SQLite-backed) plus the
|
|
4
|
+
// click-through funnel aggregate. Reads from GET /api/flashback/history.
|
|
5
|
+
// Filter: time-window selector (1d / 7d / 30d / all). One round-trip per
|
|
6
|
+
// view; the server returns events + funnel in one response.
|
|
7
|
+
//
|
|
8
|
+
// Zero-state handling is intentional and prominent: an empty table after
|
|
9
|
+
// 7+ days of normal use IS the diagnostic signal Joshua needs (he's been
|
|
10
|
+
// flashback-blind in past sprints; "0 fires" tells him to investigate the
|
|
11
|
+
// PTY → analyzer → bridge → emit pipeline, not silently degrade).
|
|
12
|
+
//
|
|
13
|
+
// Vanilla JS, no framework — matches the rest of public/.
|
|
14
|
+
|
|
15
|
+
(() => {
|
|
16
|
+
const API = window.location.origin;
|
|
17
|
+
|
|
18
|
+
const els = {
|
|
19
|
+
windowSel: document.getElementById('fbWindow'),
|
|
20
|
+
refreshBtn: document.getElementById('fbRefresh'),
|
|
21
|
+
errorBanner: document.getElementById('fbErrorBanner'),
|
|
22
|
+
content: document.getElementById('fbContent'),
|
|
23
|
+
barFires: document.getElementById('fbBarFires'),
|
|
24
|
+
barDismissed: document.getElementById('fbBarDismissed'),
|
|
25
|
+
barClicked: document.getElementById('fbBarClicked'),
|
|
26
|
+
countFires: document.getElementById('fbCountFires'),
|
|
27
|
+
countDismissed: document.getElementById('fbCountDismissed'),
|
|
28
|
+
countClicked: document.getElementById('fbCountClicked'),
|
|
29
|
+
pctDismissed: document.getElementById('fbPctDismissed'),
|
|
30
|
+
pctClicked: document.getElementById('fbPctClicked'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// URL state: ?window=7d persists across reload.
|
|
34
|
+
function loadStateFromUrl() {
|
|
35
|
+
const qs = new URLSearchParams(window.location.search);
|
|
36
|
+
const win = qs.get('window');
|
|
37
|
+
if (win && ['1d', '7d', '30d', 'all'].includes(win)) {
|
|
38
|
+
els.windowSel.value = win;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeStateToUrl() {
|
|
42
|
+
const qs = new URLSearchParams(window.location.search);
|
|
43
|
+
qs.set('window', els.windowSel.value);
|
|
44
|
+
const next = `${window.location.pathname}?${qs.toString()}`;
|
|
45
|
+
window.history.replaceState(null, '', next);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Compute ISO timestamp for the "since" filter from the window selector.
|
|
49
|
+
// Returns null for "all time" (no filter).
|
|
50
|
+
function sinceFromWindow(key) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const ms = {
|
|
53
|
+
'1d': 24 * 60 * 60 * 1000,
|
|
54
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
55
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
56
|
+
}[key];
|
|
57
|
+
if (!ms) return null;
|
|
58
|
+
return new Date(now - ms).toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeHtml(s) {
|
|
62
|
+
if (s == null) return '';
|
|
63
|
+
return String(s)
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/'/g, ''');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fmtTime(iso) {
|
|
72
|
+
if (!iso) return '—';
|
|
73
|
+
try {
|
|
74
|
+
const d = new Date(iso);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const diffMs = now - d.getTime();
|
|
77
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
78
|
+
if (diffSec < 60) return `${diffSec}s ago`;
|
|
79
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
80
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
81
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
82
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
83
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
84
|
+
if (diffDay < 7) return `${diffDay}d ago`;
|
|
85
|
+
// Older than a week: show the date.
|
|
86
|
+
return d.toISOString().slice(0, 10);
|
|
87
|
+
} catch {
|
|
88
|
+
return iso;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fmtScore(score) {
|
|
93
|
+
if (score == null || !Number.isFinite(score)) return '—';
|
|
94
|
+
return `${(score * 100).toFixed(0)}%`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderFunnel(funnel) {
|
|
98
|
+
const fires = Number(funnel?.fires || 0);
|
|
99
|
+
const dismissed = Number(funnel?.dismissed || 0);
|
|
100
|
+
const clicked = Number(funnel?.clicked_through || 0);
|
|
101
|
+
|
|
102
|
+
els.countFires.textContent = String(fires);
|
|
103
|
+
els.countDismissed.textContent = String(dismissed);
|
|
104
|
+
els.countClicked.textContent = String(clicked);
|
|
105
|
+
|
|
106
|
+
// Bar widths: fires is always 100% (the cohort baseline); dismissed and
|
|
107
|
+
// clicked are proportions of fires. Ratio out of fires (not out of
|
|
108
|
+
// dismissed) keeps the funnel-shape visual intuitive.
|
|
109
|
+
els.barFires.style.width = fires > 0 ? '100%' : '0%';
|
|
110
|
+
if (fires > 0) {
|
|
111
|
+
els.barDismissed.style.width = `${(dismissed / fires) * 100}%`;
|
|
112
|
+
els.barClicked.style.width = `${(clicked / fires) * 100}%`;
|
|
113
|
+
els.pctDismissed.textContent = ` · ${Math.round((dismissed / fires) * 100)}%`;
|
|
114
|
+
els.pctClicked.textContent = ` · ${Math.round((clicked / fires) * 100)}%`;
|
|
115
|
+
} else {
|
|
116
|
+
els.barDismissed.style.width = '0%';
|
|
117
|
+
els.barClicked.style.width = '0%';
|
|
118
|
+
els.pctDismissed.textContent = '';
|
|
119
|
+
els.pctClicked.textContent = '';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderZeroState(windowKey) {
|
|
124
|
+
const windowLabel = {
|
|
125
|
+
'1d': 'the last 24 hours',
|
|
126
|
+
'7d': 'the last 7 days',
|
|
127
|
+
'30d': 'the last 30 days',
|
|
128
|
+
'all': 'recorded history',
|
|
129
|
+
}[windowKey] || 'the selected window';
|
|
130
|
+
|
|
131
|
+
els.content.innerHTML = `
|
|
132
|
+
<div class="fb-zero">
|
|
133
|
+
<h3>0 fires in ${escapeHtml(windowLabel)}</h3>
|
|
134
|
+
<p>Flashback might not be firing at all — or the underlying RAG isn't returning hits. Investigate the pipeline:</p>
|
|
135
|
+
<p>
|
|
136
|
+
<code>GET /api/flashback/diag?eventType=pattern_match</code> —
|
|
137
|
+
are PTY errors being detected?
|
|
138
|
+
</p>
|
|
139
|
+
<p>
|
|
140
|
+
<code>GET /api/flashback/diag?eventType=bridge_query</code> —
|
|
141
|
+
are queries reaching Mnestra?
|
|
142
|
+
</p>
|
|
143
|
+
<p>
|
|
144
|
+
<code>GET /api/flashback/diag?eventType=proactive_memory_emit</code> —
|
|
145
|
+
are emits being attempted, and what's the outcome?
|
|
146
|
+
</p>
|
|
147
|
+
<p>
|
|
148
|
+
A populated <code>diag</code> ring with no <code>flashback_events</code>
|
|
149
|
+
rows means the WS send is failing or the toast is being dropped on
|
|
150
|
+
the client side. An empty <code>diag</code> ring means the
|
|
151
|
+
analyzer never even matched.
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderTable(events) {
|
|
158
|
+
const rows = events.map((e) => {
|
|
159
|
+
const projectCell = e.project
|
|
160
|
+
? `<span class="fb-cell-project">${escapeHtml(e.project)}</span>`
|
|
161
|
+
: `<span class="fb-cell-project" style="color:var(--tg-text-dim)">—</span>`;
|
|
162
|
+
|
|
163
|
+
const statusPills = [];
|
|
164
|
+
if (e.clicked_through) {
|
|
165
|
+
statusPills.push(`<span class="fb-pill fb-pill-clicked">clicked</span>`);
|
|
166
|
+
} else if (e.dismissed_at) {
|
|
167
|
+
statusPills.push(`<span class="fb-pill fb-pill-dismissed">dismissed</span>`);
|
|
168
|
+
} else {
|
|
169
|
+
statusPills.push(`<span class="fb-pill fb-pill-pending">pending</span>`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const errorPreview = (e.error_text || '').slice(0, 200);
|
|
173
|
+
|
|
174
|
+
return `
|
|
175
|
+
<tr>
|
|
176
|
+
<td class="fb-cell-time" title="${escapeHtml(e.fired_at || '')}">${escapeHtml(fmtTime(e.fired_at))}</td>
|
|
177
|
+
<td>${projectCell}</td>
|
|
178
|
+
<td class="fb-cell-error" title="${escapeHtml(e.error_text || '')}">${escapeHtml(errorPreview)}</td>
|
|
179
|
+
<td class="fb-cell-hits">${escapeHtml(String(e.hits_count ?? 0))}</td>
|
|
180
|
+
<td class="fb-cell-score">${escapeHtml(fmtScore(e.top_hit_score))}</td>
|
|
181
|
+
<td class="fb-cell-status">${statusPills.join('')}</td>
|
|
182
|
+
</tr>
|
|
183
|
+
`;
|
|
184
|
+
}).join('');
|
|
185
|
+
|
|
186
|
+
els.content.innerHTML = `
|
|
187
|
+
<div class="fb-table-wrap">
|
|
188
|
+
<table class="fb-table">
|
|
189
|
+
<thead>
|
|
190
|
+
<tr>
|
|
191
|
+
<th>When</th>
|
|
192
|
+
<th>Project</th>
|
|
193
|
+
<th>Search context</th>
|
|
194
|
+
<th style="text-align:right">Hits</th>
|
|
195
|
+
<th style="text-align:right">Score</th>
|
|
196
|
+
<th>Status</th>
|
|
197
|
+
</tr>
|
|
198
|
+
</thead>
|
|
199
|
+
<tbody>${rows}</tbody>
|
|
200
|
+
</table>
|
|
201
|
+
</div>
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function showError(msg) {
|
|
206
|
+
els.errorBanner.hidden = false;
|
|
207
|
+
els.errorBanner.textContent = msg;
|
|
208
|
+
}
|
|
209
|
+
function clearError() {
|
|
210
|
+
els.errorBanner.hidden = true;
|
|
211
|
+
els.errorBanner.textContent = '';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function refresh() {
|
|
215
|
+
clearError();
|
|
216
|
+
els.content.innerHTML = `<div class="fb-loading">Loading flashback history…</div>`;
|
|
217
|
+
|
|
218
|
+
const winKey = els.windowSel.value || '7d';
|
|
219
|
+
const since = sinceFromWindow(winKey);
|
|
220
|
+
const qs = new URLSearchParams();
|
|
221
|
+
if (since) qs.set('since', since);
|
|
222
|
+
qs.set('limit', '200');
|
|
223
|
+
|
|
224
|
+
let data;
|
|
225
|
+
try {
|
|
226
|
+
const res = await fetch(`${API}/api/flashback/history?${qs.toString()}`);
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
throw new Error(`HTTP ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
data = await res.json();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
showError(`Failed to load flashback history: ${err.message}`);
|
|
233
|
+
els.content.innerHTML = '';
|
|
234
|
+
renderFunnel({ fires: 0, dismissed: 0, clicked_through: 0 });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
renderFunnel(data.funnel || { fires: 0, dismissed: 0, clicked_through: 0 });
|
|
239
|
+
|
|
240
|
+
if (!Array.isArray(data.events) || data.events.length === 0) {
|
|
241
|
+
renderZeroState(winKey);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
renderTable(data.events);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Wire controls
|
|
249
|
+
els.windowSel.addEventListener('change', () => {
|
|
250
|
+
writeStateToUrl();
|
|
251
|
+
refresh();
|
|
252
|
+
});
|
|
253
|
+
els.refreshBtn.addEventListener('click', () => refresh());
|
|
254
|
+
|
|
255
|
+
// Boot
|
|
256
|
+
loadStateFromUrl();
|
|
257
|
+
refresh();
|
|
258
|
+
})();
|