@winspan/claude-forge 8.11.0 → 8.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.
@@ -4,995 +4,897 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Claude Forge Dashboard</title>
7
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
7
+ <script src="vendor/chart.umd.min.js"></script>
8
8
  <style>
9
+ /* === Reset & Theme === */
9
10
  * { margin: 0; padding: 0; box-sizing: border-box; }
10
11
  :root {
11
- --bg: #0a0e1a; --bg-card: #111827; --bg-card-hover: #1a2236;
12
- --border: #1e293b; --border-hover: #334155;
13
- --text: #e2e8f0; --text-dim: #64748b; --text-muted: #94a3b8;
14
- --accent: #818cf8; --accent-glow: rgba(129,140,248,0.15);
15
- --green: #34d399; --yellow: #fbbf24; --red: #f87171; --blue: #60a5fa; --purple: #a78bfa;
16
- --bg-secondary: rgba(0,0,0,0.3);
17
- --radius: 12px; --radius-sm: 8px;
12
+ --bg: #f7f8fa;
13
+ --bg-card: #ffffff;
14
+ --bg-hover: #f1f3f7;
15
+ --bg-secondary: #f7f8fa;
16
+ --border: #e4e7ec;
17
+ --border-hover: #cfd4dc;
18
+ --text: #111827;
19
+ --text-muted: #4b5563;
20
+ --text-dim: #9ca3af;
21
+ --primary: #4f46e5;
22
+ --primary-hover: #4338ca;
23
+ --primary-soft: #eef2ff;
24
+ --primary-border: #c7d2fe;
25
+ --green: #16a34a; --green-soft: #dcfce7;
26
+ --yellow: #d97706; --yellow-soft: #fef3c7;
27
+ --red: #dc2626; --red-soft: #fee2e2;
28
+ --blue: #2563eb; --blue-soft: #dbeafe;
29
+ --purple: #7c3aed; --purple-soft: #ede9fe;
30
+ --radius: 10px;
31
+ --radius-sm: 6px;
32
+ --shadow-sm: 0 1px 2px rgba(16,24,40,0.04);
33
+ --shadow: 0 1px 3px rgba(16,24,40,0.06), 0 1px 2px rgba(16,24,40,0.04);
34
+ --shadow-lg: 0 4px 12px rgba(16,24,40,0.08);
35
+ --sidebar-width: 240px;
36
+ --drawer-width: 520px;
18
37
  }
19
- body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
20
- .nav { display: flex; align-items: center; gap: 0.25rem; padding: 0.75rem 2rem; background: var(--bg-card); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); position: sticky; top: 0; z-index: 100; }
21
- .nav .logo { font-weight: 700; font-size: 1rem; background: linear-gradient(135deg, var(--accent), #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-right: 1.5rem; letter-spacing: -0.02em; }
22
- .nav a { color: var(--text-muted); text-decoration: none; padding: 0.4rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; transition: all 0.2s; }
23
- .nav a.active { color: var(--text); background: var(--accent-glow); border: 1px solid rgba(129,140,248,0.3); }
24
- .nav a:hover:not(.active) { color: var(--text); background: rgba(255,255,255,0.05); }
25
- .container { max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; }
26
- .page { display: none; }
27
- .page.active { display: block; }
28
- h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1.25rem; letter-spacing: -0.02em; }
29
- h2 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.01em; }
30
- h3 { font-size: 0.95rem; font-weight: 600; }
31
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
32
- .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; transition: all 0.2s; }
33
- .card:hover { border-color: var(--border-hover); background: var(--bg-card-hover); }
34
- .card .label { font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
35
- .card .value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; background: linear-gradient(135deg, var(--text), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
36
- table { width: 100%; border-collapse: collapse; background: var(--bg-card); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); }
37
- th, td { padding: 0.625rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
38
- th { background: rgba(0,0,0,0.3); color: var(--text-dim); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
39
- td { font-size: 0.875rem; }
40
- tr:hover td { background: rgba(255,255,255,0.02); }
41
- .badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 0.7rem; font-weight: 500; letter-spacing: 0.02em; }
42
- .badge-block { background: rgba(248,113,113,0.15); color: var(--red); border: 1px solid rgba(248,113,113,0.2); }
43
- .badge-warn { background: rgba(251,191,36,0.15); color: var(--yellow); border: 1px solid rgba(251,191,36,0.2); }
44
- .badge-allow { background: rgba(52,211,153,0.15); color: var(--green); border: 1px solid rgba(52,211,153,0.2); }
45
- .badge-info { background: var(--accent-glow); color: var(--accent); border: 1px solid rgba(129,140,248,0.2); }
46
- .empty { text-align: center; padding: 3rem; color: var(--text-dim); }
47
- .btn { background: var(--bg-card); color: var(--text-muted); border: 1px solid var(--border); padding: 0.4rem 0.875rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.8rem; transition: all 0.2s; }
48
- .btn:hover { border-color: var(--accent); color: var(--text); background: var(--accent-glow); }
49
- .btn-primary { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border: none; }
50
- .btn-primary:hover { opacity: 0.9; }
51
- select.btn { appearance: none; padding-right: 1.5rem; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; }
52
- .refresh { background: var(--bg-card); color: var(--text-muted); border: 1px solid var(--border); padding: 0.4rem 0.875rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.8rem; transition: all 0.2s; margin-bottom: 0; }
53
- .refresh:hover { border-color: var(--accent); color: var(--text); background: var(--accent-glow); }
54
- .session-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s; }
55
- .session-card:hover { border-color: var(--accent); box-shadow: 0 0 20px var(--accent-glow); transform: translateY(-1px); }
56
- .task-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 0.75rem; overflow: hidden; transition: all 0.2s; }
57
- .task-card:hover { border-color: var(--border-hover); }
58
- .task-header { padding: 1rem 1.25rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
59
- .task-body { padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--border); }
60
- .task-section { margin-top: 1rem; }
61
- .task-section-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
62
- .task-item { margin-left: 1rem; margin-bottom: 0.375rem; font-size: 0.85rem; color: var(--text-muted); }
63
- .inj-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 0.5rem; }
64
- .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
65
- .dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
66
- .dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
67
- @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
68
- .fade-in { animation: fadeIn 0.3s ease-out; }
69
- .chart-container { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem; }
70
- .chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.5rem; margin-bottom: 1.5rem; }
71
- .search-box { width: 100%; padding: 0.625rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-size: 0.875rem; margin-bottom: 1rem; transition: all 0.2s; }
72
- .search-box:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
73
- .rule-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 0.75rem; cursor: pointer; transition: all 0.2s; }
74
- .rule-card:hover { border-color: var(--accent); box-shadow: 0 0 20px var(--accent-glow); transform: translateY(-1px); }
75
- .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; background: var(--bg-secondary); color: var(--text-muted); margin-right: 0.25rem; }
38
+ html, body { height: 100%; }
39
+ body {
40
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ line-height: 1.55;
44
+ -webkit-font-smoothing: antialiased;
45
+ }
46
+
47
+ /* === Layout === */
48
+ .app { display: flex; min-height: 100vh; position: relative; }
49
+
50
+ /* Sidebar */
51
+ .sidebar {
52
+ width: var(--sidebar-width);
53
+ background: var(--bg-card);
54
+ border-right: 1px solid var(--border);
55
+ padding: 1.25rem 0.75rem;
56
+ position: fixed; top: 0; left: 0; bottom: 0;
57
+ display: flex; flex-direction: column;
58
+ overflow-y: auto;
59
+ z-index: 100;
60
+ }
61
+ .sidebar .brand {
62
+ display: flex; align-items: center; gap: 0.625rem;
63
+ padding: 0.5rem 0.75rem 1.25rem;
64
+ border-bottom: 1px solid var(--border);
65
+ margin-bottom: 1rem;
66
+ }
67
+ .sidebar .brand-icon {
68
+ width: 32px; height: 32px; border-radius: 8px;
69
+ background: linear-gradient(135deg, var(--primary), var(--purple));
70
+ display: flex; align-items: center; justify-content: center;
71
+ color: white; font-weight: 700; font-size: 0.95rem;
72
+ box-shadow: var(--shadow);
73
+ }
74
+ .sidebar .brand-text {
75
+ font-weight: 600; font-size: 0.95rem;
76
+ color: var(--text); letter-spacing: -0.01em;
77
+ }
78
+ .sidebar .nav-section-title {
79
+ font-size: 0.7rem; font-weight: 600; color: var(--text-dim);
80
+ text-transform: uppercase; letter-spacing: 0.06em;
81
+ padding: 0.75rem 0.875rem 0.375rem;
82
+ }
83
+ .sidebar a {
84
+ display: flex; align-items: center; gap: 0.625rem;
85
+ padding: 0.5rem 0.875rem; margin: 1px 0;
86
+ color: var(--text-muted); text-decoration: none;
87
+ border-radius: var(--radius-sm); cursor: pointer;
88
+ font-size: 0.875rem; font-weight: 500;
89
+ transition: background 0.15s, color 0.15s;
90
+ }
91
+ .sidebar a:hover { background: var(--bg-hover); color: var(--text); }
92
+ .sidebar a.active {
93
+ background: var(--primary-soft);
94
+ color: var(--primary);
95
+ }
96
+ .nav-icon { width: 18px; height: 18px; flex-shrink: 0; }
97
+
98
+ /* Main content */
99
+ .main {
100
+ flex: 1;
101
+ margin-left: var(--sidebar-width);
102
+ min-height: 100vh;
103
+ }
104
+ .topbar {
105
+ height: 60px;
106
+ background: var(--bg-card);
107
+ border-bottom: 1px solid var(--border);
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ padding: 0 1.75rem;
112
+ position: sticky;
113
+ top: 0;
114
+ z-index: 50;
115
+ }
116
+ .topbar .page-title {
117
+ font-size: 1rem;
118
+ font-weight: 600;
119
+ color: var(--text);
120
+ }
121
+ .topbar .actions {
122
+ display: flex;
123
+ gap: 0.5rem;
124
+ align-items: center;
125
+ }
126
+ .container {
127
+ padding: 1.5rem 1.75rem;
128
+ max-width: 1400px;
129
+ }
130
+ .page {
131
+ display: none;
132
+ }
133
+ .page.active {
134
+ display: block;
135
+ }
136
+
137
+ /* Drawer (侧边抽屉) */
138
+ .drawer-overlay {
139
+ position: fixed;
140
+ top: 0;
141
+ left: 0;
142
+ right: 0;
143
+ bottom: 0;
144
+ background: rgba(0, 0, 0, 0.4);
145
+ z-index: 200;
146
+ opacity: 0;
147
+ visibility: hidden;
148
+ transition: opacity 0.2s, visibility 0.2s;
149
+ }
150
+ .drawer-overlay.active {
151
+ opacity: 1;
152
+ visibility: visible;
153
+ }
154
+ .drawer {
155
+ position: fixed;
156
+ top: 0;
157
+ right: 0;
158
+ bottom: 0;
159
+ width: var(--drawer-width);
160
+ background: var(--bg-card);
161
+ border-left: 1px solid var(--border);
162
+ box-shadow: var(--shadow-lg);
163
+ z-index: 201;
164
+ display: flex;
165
+ flex-direction: column;
166
+ transform: translateX(100%);
167
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
168
+ }
169
+ .drawer.active {
170
+ transform: translateX(0);
171
+ }
172
+ .drawer-header {
173
+ display: flex;
174
+ align-items: center;
175
+ justify-content: space-between;
176
+ padding: 1.25rem 1.5rem;
177
+ border-bottom: 1px solid var(--border);
178
+ flex-shrink: 0;
179
+ }
180
+ .drawer-title {
181
+ font-size: 1rem;
182
+ font-weight: 600;
183
+ color: var(--text);
184
+ }
185
+ .drawer-close {
186
+ width: 32px; height: 32px;
187
+ border-radius: var(--radius-sm);
188
+ border: 1px solid var(--border);
189
+ background: var(--bg-card);
190
+ cursor: pointer;
191
+ display: flex; align-items: center; justify-content: center;
192
+ color: var(--text-muted);
193
+ transition: all 0.15s;
194
+ }
195
+ .drawer-close:hover {
196
+ background: var(--bg-hover);
197
+ color: var(--text);
198
+ }
199
+ .drawer-body {
200
+ flex: 1;
201
+ overflow-y: auto;
202
+ padding: 1.5rem;
203
+ }
204
+
205
+ /* === Typography === */
206
+ h1 { font-size: 1.375rem; font-weight: 600; margin-bottom: 1.25rem; letter-spacing: -0.02em; }
207
+ h2 { font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em; }
208
+ h3 { font-size: 0.9rem; font-weight: 600; }
209
+
210
+ /* === Stat cards === */
211
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
212
+ .stat-card {
213
+ background: var(--bg-card);
214
+ border: 1px solid var(--border);
215
+ border-radius: var(--radius);
216
+ padding: 1.125rem 1.25rem;
217
+ box-shadow: var(--shadow-sm);
218
+ }
219
+ .stat-card .label { font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
220
+ .stat-card .value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; color: var(--text); letter-spacing: -0.02em; }
221
+
222
+ /* === Grid layouts === */
223
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
224
+ .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.25rem; }
225
+ @media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }
226
+
227
+ /* === Panel (white box) === */
228
+ .panel {
229
+ background: var(--bg-card);
230
+ border: 1px solid var(--border);
231
+ border-radius: var(--radius);
232
+ box-shadow: var(--shadow-sm);
233
+ overflow: hidden;
234
+ }
235
+ .panel-header {
236
+ display: flex; align-items: center; justify-content: space-between;
237
+ padding: 1rem 1.25rem;
238
+ border-bottom: 1px solid var(--border);
239
+ }
240
+ .panel-title { font-size: 0.875rem; font-weight: 600; color: var(--text); }
241
+ .panel-body { padding: 1.25rem; }
242
+
243
+ /* === Table === */
244
+ table { width: 100%; border-collapse: collapse; }
245
+ th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
246
+ th { background: var(--bg-secondary); color: var(--text-muted); font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
247
+ td { font-size: 0.875rem; color: var(--text); }
248
+ tr:last-child td { border-bottom: none; }
249
+ tr:hover td { background: var(--bg-hover); cursor: pointer; }
250
+
251
+ /* === Badges === */
252
+ .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 0.7rem; font-weight: 500; }
253
+ .badge-block { background: var(--red-soft); color: var(--red); }
254
+ .badge-warn { background: var(--yellow-soft); color: var(--yellow); }
255
+ .badge-allow { background: var(--green-soft); color: var(--green); }
256
+ .badge-info { background: var(--primary-soft); color: var(--primary); }
257
+ .badge-live { background: var(--red-soft); color: var(--red); animation: pulse 1.5s infinite; }
258
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
259
+
260
+ /* === Buttons === */
261
+ .btn { background: var(--bg-card); color: var(--text-muted); border: 1px solid var(--border); padding: 0.45rem 0.875rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.8125rem; font-weight: 500; transition: all 0.15s; }
262
+ .btn:hover { border-color: var(--border-hover); color: var(--text); background: var(--bg-hover); }
263
+ .btn-primary { background: var(--primary); color: white; border-color: var(--primary); }
264
+ .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: white; }
265
+ select.btn { appearance: none; padding-right: 1.75rem; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.6rem center; }
266
+
267
+ /* === Search === */
268
+ .search-box { padding: 0.55rem 0.875rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-size: 0.875rem; transition: border-color 0.15s, box-shadow 0.15s; min-width: 240px; }
269
+ .search-box::placeholder { color: var(--text-dim); }
270
+ .search-box:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,0.12); }
271
+
272
+ /* === Toolbar (above tables/lists) === */
273
+ .toolbar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
274
+
275
+ /* === List items === */
276
+ .list-item {
277
+ display: flex; align-items: center; justify-content: space-between;
278
+ padding: 0.875rem 1.25rem;
279
+ border-bottom: 1px solid var(--border);
280
+ cursor: pointer;
281
+ transition: background 0.12s;
282
+ }
283
+ .list-item:last-child { border-bottom: none; }
284
+ .list-item:hover { background: var(--bg-hover); }
285
+ .list-item.selected { background: var(--primary-soft); }
286
+
287
+ /* === Empty state === */
288
+ .empty { text-align: center; padding: 3rem 1rem; color: var(--text-dim); }
289
+ .empty-icon { font-size: 2rem; margin-bottom: 0.75rem; }
290
+ .empty-text { font-size: 0.875rem; }
291
+
292
+ /* === Loading === */
293
+ .loading { text-align: center; padding: 2rem; color: var(--text-dim); font-size: 0.875rem; }
294
+
295
+ /* === Tags === */
296
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; background: var(--bg-secondary); color: var(--text-muted); border: 1px solid var(--border); margin-right: 0.25rem; }
297
+
298
+ /* === Detail sections in drawer === */
299
+ .detail-section { margin-bottom: 1.5rem; }
300
+ .detail-label { font-size: 0.75rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.375rem; }
301
+ .detail-value { font-size: 0.875rem; color: var(--text); }
302
+ pre.detail-code { font-family: ui-monospace, monospace; font-size: 0.8rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.75rem; overflow-x: auto; white-space: pre-wrap; }
303
+
304
+ /* === Live log === */
305
+ .live-log { font-family: ui-monospace, monospace; font-size: 0.8rem; max-height: 65vh; overflow-y: auto; background: #0f172a; color: #e2e8f0; border-radius: var(--radius); padding: 1rem; }
306
+ .live-log-line { padding: 2px 0; border-bottom: 1px solid #1e293b; }
307
+
308
+ /* === Chart container === */
309
+ .chart-wrap { position: relative; height: 220px; }
310
+
311
+ /* === Animations === */
312
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
313
+ .fade-in { animation: fadeIn 0.2s ease-out; }
314
+
315
+ /* === Activity feed === */
316
+ .activity-item { display: flex; gap: 0.75rem; padding: 0.625rem 0; border-bottom: 1px solid var(--border); }
317
+ .activity-item:last-child { border-bottom: none; }
318
+ .activity-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
319
+ .activity-dot-green { background: var(--green); }
320
+ .activity-dot-blue { background: var(--blue); }
321
+ .activity-dot-yellow { background: var(--yellow); }
322
+ .activity-dot-red { background: var(--red); }
323
+ .activity-content { flex: 1; min-width: 0; }
324
+ .activity-title { font-size: 0.8125rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
325
+ .activity-time { font-size: 0.75rem; color: var(--text-dim); margin-top: 1px; }
76
326
  </style>
77
327
  </head>
78
328
  <body>
79
- <nav class="nav">
80
- <span class="logo">Claude Forge</span>
81
- <a onclick="showPage('dashboard')" class="active" id="nav-dashboard">Dashboard</a>
82
- <a onclick="showPage('stats')" id="nav-stats">Stats</a>
83
- <a onclick="showPage('events')" id="nav-events">Events</a>
84
- <a onclick="showPage('injections')" id="nav-injections">Injections</a>
85
- <a onclick="showPage('live')" id="nav-live">Live</a>
86
- <a onclick="showPage('rules')" id="nav-rules">Rules</a>
87
- </nav>
88
-
89
- <div class="container">
90
- <!-- Dashboard -->
91
- <div id="page-dashboard" class="page active">
92
- <h1>Dashboard</h1>
93
- <div class="cards" id="status-cards"></div>
94
- <h1 style="margin-top:2rem">Recent Sessions</h1>
95
- <div id="sessions-list"></div>
329
+ <div class="app">
330
+ <!-- Sidebar -->
331
+ <aside class="sidebar">
332
+ <div class="brand">
333
+ <div class="brand-icon">CF</div>
334
+ <div class="brand-text">Claude Forge</div>
96
335
  </div>
97
-
98
- <!-- Session Detail: task list -->
99
- <div id="page-session-detail" class="page">
100
- <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem">
101
- <button class="btn" onclick="showPage('dashboard')">← Back</button>
102
- <h1 style="margin-bottom:0">Session Detail</h1>
103
- <button class="btn" id="timeline-view-btn" onclick="toggleTimelineView()">切换视图</button>
104
- <span id="current-view-label" style="font-size:0.85rem;color:var(--text-muted)">时间线视图</span>
336
+ <div class="nav-section-title">Overview</div>
337
+ <a onclick="nav('dashboard')" id="nav-dashboard" class="active">
338
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
339
+ Dashboard
340
+ </a>
341
+ <div class="nav-section-title">Activity</div>
342
+ <a onclick="nav('sessions')" id="nav-sessions">
343
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
344
+ Sessions
345
+ </a>
346
+ <a onclick="nav('events')" id="nav-events">
347
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
348
+ Events
349
+ </a>
350
+ <a onclick="nav('injections')" id="nav-injections">
351
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
352
+ Injections
353
+ </a>
354
+ <a onclick="nav('live')" id="nav-live">
355
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg>
356
+ Live
357
+ </a>
358
+ <div class="nav-section-title">Configuration</div>
359
+ <a onclick="nav('rules')" id="nav-rules">
360
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
361
+ Rules
362
+ </a>
363
+ </aside>
364
+
365
+ <!-- Main -->
366
+ <main class="main">
367
+ <div class="topbar">
368
+ <div class="page-title" id="topbar-title">Dashboard</div>
369
+ <div class="actions">
370
+ <span id="daemon-status" class="badge badge-allow" style="font-size:0.75rem">Checking...</span>
371
+ <button class="btn" onclick="refreshPage()">↻ Refresh</button>
105
372
  </div>
106
- <div id="session-detail"></div>
107
373
  </div>
108
-
109
- <!-- Task Detail: single task deep view -->
110
- <div id="page-task-detail" class="page">
111
- <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem">
112
- <button class="btn" onclick="backToSession()">← 返回任务列表</button>
113
- <h1 style="margin-bottom:0" id="task-detail-title">Task Detail</h1>
374
+ <div class="container">
375
+
376
+ <!-- Dashboard -->
377
+ <div id="page-dashboard" class="page active">
378
+ <div class="cards" id="dash-stats"></div>
379
+ <div class="grid-2" style="margin-bottom:1.25rem">
380
+ <div class="panel">
381
+ <div class="panel-header">
382
+ <span class="panel-title">7-Day Activity</span>
383
+ </div>
384
+ <div class="panel-body"><div class="chart-wrap"><canvas id="chart-activity"></canvas></div></div>
385
+ </div>
386
+ <div class="panel">
387
+ <div class="panel-header">
388
+ <span class="panel-title">Tool Usage</span>
389
+ </div>
390
+ <div class="panel-body"><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
391
+ </div>
392
+ </div>
393
+ <div class="grid-2">
394
+ <div class="panel">
395
+ <div class="panel-header">
396
+ <span class="panel-title">Recent Sessions</span>
397
+ <a onclick="nav('sessions')" style="font-size:0.8rem;color:var(--primary);cursor:pointer">View all →</a>
398
+ </div>
399
+ <div id="dash-sessions"></div>
400
+ </div>
401
+ <div class="panel">
402
+ <div class="panel-header">
403
+ <span class="panel-title">Recent Activity</span>
404
+ </div>
405
+ <div class="panel-body" id="dash-activity"></div>
406
+ </div>
407
+ </div>
114
408
  </div>
115
- <div id="task-detail"></div>
116
- </div>
117
409
 
118
- <!-- Stats -->
119
- <div id="page-stats" class="page">
120
- <h1>Statistics</h1>
121
- <div class="chart-grid">
122
- <div class="chart-container">
123
- <h3 style="margin-bottom:1rem">Tool Usage Distribution</h3>
124
- <canvas id="chart-tool-usage"></canvas>
410
+ <!-- Sessions -->
411
+ <div id="page-sessions" class="page">
412
+ <div class="toolbar">
413
+ <input class="search-box" id="sessions-search" placeholder="Search sessions..." oninput="filterSessions()">
125
414
  </div>
126
- <div class="chart-container">
127
- <h3 style="margin-bottom:1rem">Decision Levels</h3>
128
- <canvas id="chart-decision-levels"></canvas>
415
+ <div class="panel" id="sessions-panel">
416
+ <div id="sessions-list"></div>
129
417
  </div>
130
418
  </div>
131
- <div class="chart-container">
132
- <h3 style="margin-bottom:1rem">7-Day Activity Trend</h3>
133
- <canvas id="chart-daily-activity"></canvas>
134
- </div>
135
- </div>
136
419
 
137
- <!-- Events -->
138
- <div id="page-events" class="page">
139
- <h1>Events</h1>
140
- <input type="text" class="search-box" id="events-search" placeholder="Search events by type, tool, or project..." onkeyup="filterEvents()">
141
- <button class="refresh" onclick="loadEvents()">Refresh</button>
142
- <table id="events-table">
143
- <thead><tr><th>Time</th><th>Type</th><th>Tool</th><th>Project</th></tr></thead>
144
- <tbody id="events-body"></tbody>
145
- </table>
146
- </div>
147
-
148
- <!-- Rules -->
149
- <div id="page-rules" class="page">
150
- <h1>Rules</h1>
151
- <input type="text" class="search-box" id="rules-search" placeholder="Search rules by name, category, or tags..." onkeyup="filterRules()">
152
- <div id="rules-list"></div>
153
- </div>
420
+ <!-- Events -->
421
+ <div id="page-events" class="page">
422
+ <div class="toolbar">
423
+ <input class="search-box" id="events-search" placeholder="Search by type, tool, project..." oninput="filterEvents()">
424
+ <select class="btn" id="events-type" onchange="filterEvents()">
425
+ <option value="">All Types</option>
426
+ <option value="PreToolUse">PreToolUse</option>
427
+ <option value="PostToolUse">PostToolUse</option>
428
+ <option value="UserPromptSubmit">UserPromptSubmit</option>
429
+ <option value="Stop">Stop</option>
430
+ </select>
431
+ </div>
432
+ <div class="panel">
433
+ <table>
434
+ <thead><tr><th>Time</th><th>Type</th><th>Tool</th><th>Project</th></tr></thead>
435
+ <tbody id="events-body"></tbody>
436
+ </table>
437
+ </div>
438
+ </div>
154
439
 
155
- <!-- Rule Detail -->
156
- <div id="page-rule-detail" class="page">
157
- <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem">
158
- <button class="btn" onclick="showPage('rules')">← Back to Rules</button>
159
- <h1 style="margin-bottom:0" id="rule-detail-title">Rule Detail</h1>
440
+ <!-- Injections -->
441
+ <div id="page-injections" class="page">
442
+ <div class="toolbar">
443
+ <input class="search-box" id="inj-search" placeholder="Search injections..." oninput="filterInjections()">
444
+ <select class="btn" id="inj-handler" onchange="filterInjections()">
445
+ <option value="">All Handlers</option>
446
+ <option value="UserPromptSubmitHandler">UserPromptSubmit</option>
447
+ <option value="PreToolUseHandler">PreToolUse</option>
448
+ <option value="PostToolUseHandler">PostToolUse</option>
449
+ </select>
450
+ </div>
451
+ <div class="panel" id="inj-panel">
452
+ <div id="inj-list"></div>
453
+ </div>
160
454
  </div>
161
- <div id="rule-detail"></div>
162
- </div>
163
455
 
164
- <!-- Injections -->
165
- <div id="page-injections" class="page">
166
- <h1>Injections</h1>
167
- <input type="text" class="search-box" id="injections-search" placeholder="Search injections by type or content..." onkeyup="filterInjections()">
168
- <div style="margin-bottom:1rem;display:flex;gap:0.5rem;align-items:center">
169
- <select id="inj-session" class="refresh" onchange="loadInjections()"><option value="">All Sessions</option></select>
170
- <select id="inj-handler" class="refresh" onchange="loadInjections()">
171
- <option value="">All Handlers</option>
172
- <option value="UserPromptSubmitHandler">UserPromptSubmitHandler</option>
173
- <option value="PreToolUseHandler">PreToolUseHandler</option>
174
- <option value="PostToolUseHandler">PostToolUseHandler</option>
175
- </select>
176
- <button class="refresh" onclick="loadInjections()">Refresh</button>
456
+ <!-- Live -->
457
+ <div id="page-live" class="page">
458
+ <div class="toolbar">
459
+ <span id="live-status" class="badge badge-warn">Disconnected</span>
460
+ <button class="btn btn-primary" id="live-btn" onclick="toggleLive()">Connect</button>
461
+ <button class="btn" onclick="document.getElementById('live-log').innerHTML=''">Clear</button>
462
+ </div>
463
+ <div class="live-log" id="live-log"></div>
177
464
  </div>
178
- <div id="injections-list"></div>
179
- </div>
180
465
 
181
- <!-- Live Events -->
182
- <div id="page-live" class="page">
183
- <h1>Live Events</h1>
184
- <div style="margin-bottom:1rem;display:flex;gap:0.5rem;align-items:center">
185
- <span class="badge badge-allow" id="live-status">Disconnected</span>
186
- <button class="refresh" onclick="toggleLive()" id="live-toggle">Connect</button>
187
- <button class="refresh" onclick="clearLive()">Clear</button>
466
+ <!-- Rules -->
467
+ <div id="page-rules" class="page">
468
+ <div class="toolbar">
469
+ <input class="search-box" id="rules-search" placeholder="Search rules..." oninput="filterRules()">
470
+ </div>
471
+ <div class="panel" id="rules-panel">
472
+ <div id="rules-list"></div>
473
+ </div>
188
474
  </div>
189
- <div id="live-events" style="font-family:monospace;font-size:0.8rem;max-height:70vh;overflow-y:auto;background:#1e293b;border-radius:8px;padding:1rem"></div>
475
+
476
+ </div><!-- /container -->
477
+ </main>
478
+
479
+ <!-- Drawer overlay -->
480
+ <div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
481
+
482
+ <!-- Drawer -->
483
+ <div class="drawer" id="drawer">
484
+ <div class="drawer-header">
485
+ <div class="drawer-title" id="drawer-title">Detail</div>
486
+ <button class="drawer-close" onclick="closeDrawer()">
487
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
488
+ </button>
190
489
  </div>
490
+ <div class="drawer-body" id="drawer-body"></div>
191
491
  </div>
192
492
 
493
+ </div><!-- /app -->
494
+
193
495
  <script>
194
496
  const API = '';
497
+ let allEvents = [], allSessions = [], allInjections = [], allRules = [];
498
+ let liveSource = null;
499
+ let charts = {};
195
500
 
196
- function parseTime(ts) {
197
- return new Date(ts.endsWith('Z') ? ts : ts + 'Z');
198
- }
199
-
200
- function showPage(name) {
501
+ // === Navigation ===
502
+ function nav(page) {
503
+ document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
201
504
  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
202
- document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
203
- const page = document.getElementById('page-' + name);
204
- const nav = document.getElementById('nav-' + name);
205
- if (page) page.classList.add('active');
206
- if (nav) nav.classList.add('active');
207
- if (name === 'dashboard') { loadStatus(); loadSessions(); }
208
- if (name === 'stats') loadStats();
209
- if (name === 'events') loadEvents();
210
- if (name === 'injections') loadInjections();
211
- if (name === 'rules') loadRules();
212
- if (name === 'live' && !liveSource) toggleLive();
505
+ const navEl = document.getElementById('nav-' + page);
506
+ const pageEl = document.getElementById('page-' + page);
507
+ if (navEl) navEl.classList.add('active');
508
+ if (pageEl) pageEl.classList.add('active');
509
+ const titles = { dashboard:'Dashboard', sessions:'Sessions', events:'Events', injections:'Injections', live:'Live', rules:'Rules' };
510
+ document.getElementById('topbar-title').textContent = titles[page] || page;
511
+ closeDrawer();
512
+ if (page === 'dashboard') loadDashboard();
513
+ else if (page === 'sessions') loadSessions();
514
+ else if (page === 'events') loadEvents();
515
+ else if (page === 'injections') loadInjections();
516
+ else if (page === 'rules') loadRules();
517
+ else if (page === 'live' && !liveSource) toggleLive();
213
518
  }
214
519
 
215
- async function loadStatus() {
216
- try {
217
- const res = await fetch(API + '/api/status');
218
- const data = await res.json();
219
- const uptime = Math.floor(data.uptime);
220
- const uptimeStr = uptime < 60 ? uptime + 's' : Math.floor(uptime/60) + 'm';
221
- const memMB = Math.round(data.memory.heapUsed / 1024 / 1024);
222
- document.getElementById('status-cards').innerHTML = `
223
- <div class="card"><div class="label">PID</div><div class="value">${data.pid}</div></div>
224
- <div class="card"><div class="label">Uptime</div><div class="value">${uptimeStr}</div></div>
225
- <div class="card"><div class="label">Memory</div><div class="value">${memMB}MB</div></div>
226
- <div class="card"><div class="label">Events</div><div class="value">${data.eventCount}</div></div>
227
- `;
228
- } catch { document.getElementById('status-cards').innerHTML = '<div class="empty">Daemon not running</div>'; }
520
+ function refreshPage() {
521
+ const active = document.querySelector('.page.active');
522
+ if (active) nav(active.id.replace('page-', ''));
229
523
  }
230
524
 
231
- async function loadSessions() {
232
- try {
233
- const res = await fetch(API + '/api/sessions?limit=10');
234
- const sessions = await res.json();
235
- if (sessions.length === 0) {
236
- document.getElementById('sessions-list').innerHTML = '<div class="empty">No sessions found</div>';
237
- return;
238
- }
239
- document.getElementById('sessions-list').innerHTML = sessions.map(s => {
240
- const shortId = s.session_id.slice(0, 8);
241
- const start = parseTime(s.start_time).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
242
- const end = parseTime(s.end_time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
243
- const prompt = s.first_prompt ? s.first_prompt.slice(0, 60) : '(无提示词)';
244
- return `<div class="session-card fade-in" onclick="loadSessionDetail('${s.session_id}')">
245
- <div style="display:flex;justify-content:space-between;align-items:center">
246
- <div>
247
- <div style="font-size:0.75rem;color:var(--text-dim)">会话 ${shortId} · ${start} ~ ${end}</div>
248
- <div style="margin-top:0.375rem;font-size:0.95rem;font-weight:500">${prompt}</div>
249
- <div style="margin-top:0.25rem;font-size:0.75rem;color:var(--text-dim)">${s.event_count} events</div>
250
- </div>
251
- <span style="color:var(--accent);font-size:0.8rem">→</span>
252
- </div>
253
- </div>`;
254
- }).join('');
255
- } catch { document.getElementById('sessions-list').innerHTML = '<div class="empty">Failed to load</div>'; }
525
+ // === Drawer ===
526
+ function openDrawer(title, html) {
527
+ document.getElementById('drawer-title').textContent = title;
528
+ document.getElementById('drawer-body').innerHTML = html;
529
+ document.getElementById('drawer').classList.add('active');
530
+ document.getElementById('drawer-overlay').classList.add('active');
256
531
  }
257
-
258
- async function loadEvents() {
259
- try {
260
- const res = await fetch(API + '/api/events?limit=50');
261
- const events = await res.json();
262
- if (events.length === 0) {
263
- document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="empty">No events</td></tr>';
264
- return;
265
- }
266
- document.getElementById('events-body').innerHTML = events.map(e => {
267
- const time = parseTime(e.timestamp).toLocaleTimeString();
268
- const badge = e.hook_type === 'PreToolUse' ? 'info' : e.hook_type === 'Stop' ? 'warn' : 'allow';
269
- return `<tr>
270
- <td>${time}</td>
271
- <td><span class="badge badge-${badge}">${e.hook_type}</span></td>
272
- <td>${e.tool_name || '—'}</td>
273
- <td>${(e.project_path || '').split('/').pop()}</td>
274
- </tr>`;
275
- }).join('');
276
- } catch { document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="empty">Failed to load</td></tr>'; }
532
+ function closeDrawer() {
533
+ document.getElementById('drawer').classList.remove('active');
534
+ document.getElementById('drawer-overlay').classList.remove('active');
277
535
  }
278
536
 
279
- async function loadRules() {
280
- try {
281
- const res = await fetch(API + '/api/rules');
282
- const rules = await res.json();
283
- const entries = Object.entries(rules);
284
- if (entries.length === 0) {
285
- document.getElementById('rules-list').innerHTML = '<div class="empty">No rules loaded</div>';
286
- return;
287
- }
288
- document.getElementById('rules-list').innerHTML = entries.map(([id, conv]) => {
289
- const c = conv;
290
- const ruleCount = (c.rules || []).length;
291
- return `<div class="card" style="margin-bottom:1rem">
292
- <div class="label">${id} v${c.version || '?'}</div>
293
- <div class="value" style="font-size:1rem">${c.name || id}</div>
294
- <div style="margin-top:0.5rem;color:#94a3b8;font-size:0.875rem">${ruleCount} rules</div>
295
- </div>`;
296
- }).join('');
297
- } catch { document.getElementById('rules-list').innerHTML = '<div class="empty">Failed to load</div>'; }
537
+ // === Helpers ===
538
+ function fmt(ts) {
539
+ if (!ts) return '';
540
+ const d = new Date(String(ts).endsWith('Z') ? ts : ts + 'Z');
541
+ return d.toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
298
542
  }
299
-
300
- async function loadInjections() {
301
- try {
302
- const session = document.getElementById('inj-session').value;
303
- const handler = document.getElementById('inj-handler').value;
304
- let url = API + '/api/injections?limit=50';
305
- if (session) url += '&session=' + session;
306
- if (handler) url += '&handler=' + handler;
307
- const res = await fetch(url);
308
- const injections = await res.json();
309
-
310
- if (injections.length === 0) {
311
- document.getElementById('injections-list').innerHTML = '<div class="empty">No injections found</div>';
312
- return;
313
- }
314
-
315
- const sessRes = await fetch(API + '/api/sessions?limit=10');
316
- const sessions = await sessRes.json();
317
- const sessSelect = document.getElementById('inj-session');
318
- if (sessSelect.options.length === 1) {
319
- sessions.forEach(s => {
320
- const opt = document.createElement('option');
321
- opt.value = s.session_id;
322
- opt.textContent = s.session_id.slice(0,8) + ' - ' + (s.first_prompt || 'no prompt').slice(0,30);
323
- sessSelect.appendChild(opt);
324
- });
325
- }
326
-
327
- document.getElementById('injections-list').innerHTML = injections.map((inj, idx) => {
328
- const time = parseTime(inj.timestamp).toLocaleString();
329
- const icon = inj.injection_type === 'reason' ? '🚫' : '💉';
330
- const typeColor = inj.injection_type === 'systemMessage' ? '#93c5fd' : inj.injection_type === 'reason' ? '#fca5a5' : '#fcd34d';
331
- const preview = inj.content.slice(0, 200).replace(/\n/g, ' ');
332
- const needExpand = inj.content.length > 200;
333
- return `<div class="card" style="margin-bottom:0.5rem">
334
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
335
- <span style="font-size:0.75rem;color:#64748b">${time}</span>
336
- <span class="badge badge-info">${inj.source_handler}</span>
337
- </div>
338
- <div style="margin-bottom:0.5rem">
339
- <span style="font-size:0.875rem;color:${typeColor}">${icon} ${inj.injection_type}</span>
340
- <span style="font-size:0.75rem;color:#64748b;margin-left:0.5rem">${inj.content.length} chars</span>
341
- ${needExpand ? `<button class="refresh" style="margin-left:0.5rem;padding:2px 8px;font-size:0.75rem" onclick="toggleInj(${idx})">展开</button>` : ''}
342
- </div>
343
- <div id="inj-${idx}" style="font-size:0.875rem;color:#94a3b8;font-family:monospace;white-space:pre-wrap;max-height:${needExpand ? '3rem' : 'none'};overflow:hidden">${inj.content}</div>
344
- </div>`;
345
- }).join('');
346
- } catch { document.getElementById('injections-list').innerHTML = '<div class="empty">Failed to load</div>'; }
543
+ function fmtTime(ts) {
544
+ if (!ts) return '—';
545
+ const d = new Date(String(ts).endsWith('Z') ? ts : ts + 'Z');
546
+ return d.toLocaleTimeString('zh-CN', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
347
547
  }
348
-
349
- function toggleInj(idx) {
350
- const el = document.getElementById('inj-' + idx);
351
- const btn = event.target;
352
- if (el.style.maxHeight === 'none') {
353
- el.style.maxHeight = '3rem';
354
- btn.textContent = '展开';
355
- } else {
356
- el.style.maxHeight = 'none';
357
- btn.textContent = '收起';
358
- }
548
+ function badgeHook(type) {
549
+ const m = { PreToolUse:'badge-info', PostToolUse:'badge-allow', UserPromptSubmit:'badge-warn', Stop:'badge-warn' };
550
+ return `<span class="badge ${m[type]||'badge-info'}">${type||'—'}</span>`;
359
551
  }
360
-
361
- let currentSessionId = null;
362
- let currentView = 'timeline'; // 'timeline' or 'tasks'
363
- let timelineOffset = 0;
364
- let timelineData = [];
365
- let timelineHasMore = false;
366
-
367
- async function loadSessionDetail(sessionId) {
368
- currentSessionId = sessionId;
369
- timelineOffset = 0;
370
- timelineData = [];
371
- if (currentView === 'timeline') {
372
- await loadSessionTimeline(sessionId);
373
- } else {
374
- await loadSessionTasks(sessionId);
375
- }
552
+ function empty(msg) {
553
+ return `<div class="empty"><div class="empty-icon">📭</div><div class="empty-text">${msg}</div></div>`;
376
554
  }
377
-
378
- function toggleTimelineView() {
379
- currentView = currentView === 'timeline' ? 'tasks' : 'timeline';
380
- document.getElementById('current-view-label').textContent = currentView === 'timeline' ? '时间线视图' : '任务视图';
381
- if (currentSessionId) loadSessionDetail(currentSessionId);
555
+ function loading() {
556
+ return `<div class="loading">Loading...</div>`;
382
557
  }
383
558
 
384
- async function loadSessionTimeline(sessionId, append = false) {
385
- showPage('session-detail');
386
- if (!append) {
387
- document.getElementById('session-detail').innerHTML = '<div class="empty">Loading...</div>';
388
- }
389
-
559
+ // === Dashboard ===
560
+ async function loadDashboard() {
561
+ document.getElementById('dash-stats').innerHTML = loading();
390
562
  try {
391
- const res = await fetch(API + '/api/sessions/' + sessionId + '/timeline?limit=50&offset=' + timelineOffset);
392
- const data = await res.json();
393
-
394
- if (!append) {
395
- timelineData = data.timeline;
396
- } else {
397
- timelineData = timelineData.concat(data.timeline);
398
- }
399
- timelineHasMore = data.hasMore;
400
- timelineOffset = data.offset + data.limit;
563
+ const [statusRes, statsRes] = await Promise.all([
564
+ fetch(API + '/api/status'),
565
+ fetch(API + '/api/stats')
566
+ ]);
567
+ const status = await statusRes.json();
568
+ const stats = await statsRes.json();
569
+
570
+ // Stat cards
571
+ const uptime = Math.floor(status.uptime);
572
+ const uptimeStr = uptime < 60 ? uptime + 's' : Math.floor(uptime/60) + 'm';
573
+ const mem = Math.round(status.memory.heapUsed / 1024 / 1024);
574
+ document.getElementById('dash-stats').innerHTML = `
575
+ <div class="stat-card"><div class="label">PID</div><div class="value">${status.pid}</div></div>
576
+ <div class="stat-card"><div class="label">Uptime</div><div class="value">${uptimeStr}</div></div>
577
+ <div class="stat-card"><div class="label">Memory</div><div class="value">${mem}MB</div></div>
578
+ <div class="stat-card"><div class="label">Events</div><div class="value">${status.eventCount}</div></div>
579
+ `;
580
+ document.getElementById('daemon-status').textContent = 'Running';
581
+ document.getElementById('daemon-status').className = 'badge badge-allow';
401
582
 
402
- renderTimeline(data.session, timelineData, data.summary, timelineHasMore);
403
- } catch (err) {
404
- document.getElementById('session-detail').innerHTML = '<div class="empty">Failed to load: ' + err.message + '</div>';
583
+ // Charts
584
+ renderActivityChart(stats.dailyActivity || []);
585
+ renderToolChart(stats.toolUsage || {});
586
+ } catch {
587
+ document.getElementById('dash-stats').innerHTML = `<div class="stat-card"><div class="label">Status</div><div class="value" style="font-size:1rem;color:var(--red)">Offline</div></div>`;
588
+ document.getElementById('daemon-status').textContent = 'Offline';
589
+ document.getElementById('daemon-status').className = 'badge badge-block';
405
590
  }
406
- }
407
-
408
- async function loadMoreTimeline() {
409
- await loadSessionTimeline(currentSessionId, true);
410
- }
411
-
412
- function renderTimeline(session, timeline, summary, hasMore) {
413
- const shortId = session.session_id.slice(0, 8);
414
- const start = parseTime(session.start_time).toLocaleString();
415
- const end = parseTime(session.end_time).toLocaleString();
416
-
417
- let html = `
418
- <div class="card fade-in" style="margin-bottom:1.5rem">
419
- <h2 style="margin-bottom:0.5rem">会话 ${shortId}</h2>
420
- <div style="color:var(--text-muted)">${session.first_prompt || '(无提示词)'}</div>
421
- <div style="margin-top:0.5rem;font-size:0.8rem;color:var(--text-dim)">${start} ~ ${end} · ${timeline.length} events loaded</div>
422
- </div>
423
- `;
424
-
425
- timeline.forEach((item, idx) => {
426
- const time = parseTime(item.timestamp).toLocaleTimeString();
427
591
 
428
- if (item.type === 'user_input') {
429
- html += `<div class="card fade-in" style="margin-bottom:0.75rem;border-left:3px solid var(--accent)">
430
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">${time} 👤 用户输入</div>
431
- <div style="font-size:0.95rem">"${item.data.content}"</div>
432
- </div>`;
433
- } else if (item.type === 'injection') {
434
- const preview = item.data.content.slice(0, 100);
435
- html += `<div class="card fade-in" style="margin-bottom:0.75rem;border-left:3px solid var(--purple)">
436
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">${time} 💉 ${item.data.source} (${item.data.injection_type}, ${item.data.length} chars)</div>
437
- <div style="font-size:0.85rem;color:var(--text-muted)">${preview}${item.data.truncated ? '...' : ''}</div>
438
- </div>`;
439
- } else if (item.type === 'tool_call') {
440
- const tool = item.data.tool;
441
- const input = item.data.input || {};
442
- const ok = item.data.success ? '✓' : '✗';
443
- let desc = '';
444
- if (tool === 'Bash') desc = (input.command || '').slice(0, 80);
445
- else if (tool === 'Edit' || tool === 'Write' || tool === 'Read') desc = input.file_path || '';
446
- else desc = JSON.stringify(input).slice(0, 80);
447
-
448
- html += `<div class="card fade-in" style="margin-bottom:0.75rem;border-left:3px solid ${ok === '✓' ? 'var(--green)' : 'var(--red)'}">
449
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">${time} 🔧 ${tool} ${ok}</div>
450
- <div style="font-size:0.85rem;color:var(--text-muted);font-family:monospace">${desc}</div>
451
- </div>`;
452
- } else if (item.type === 'quality_check') {
453
- html += `<div class="card fade-in" style="margin-bottom:0.75rem;border-left:3px solid var(--yellow)">
454
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">${time} ⚠️ 质量检查</div>
455
- <div style="font-size:0.85rem">${JSON.stringify(item.data).slice(0, 150)}</div>
456
- </div>`;
457
- }
458
- });
459
-
460
- if (hasMore) {
461
- html += `<div style="text-align:center;margin:1.5rem 0">
462
- <button class="btn btn-primary" onclick="loadMoreTimeline()">加载更多</button>
463
- </div>`;
592
+ // Recent sessions
593
+ try {
594
+ const res = await fetch(API + '/api/sessions?limit=5');
595
+ const sessions = await res.json();
596
+ document.getElementById('dash-sessions').innerHTML = sessions.length === 0
597
+ ? empty('No sessions yet')
598
+ : sessions.map(s => sessionListItem(s)).join('');
599
+ } catch {
600
+ document.getElementById('dash-sessions').innerHTML = empty('Failed to load');
464
601
  }
465
602
 
466
- // Summary
467
- html += `<div class="card fade-in" style="margin-top:1.5rem;border:2px solid var(--accent)">
468
- <h3 style="margin-bottom:1rem">📊 任务总结</h3>
469
- ${summary.commits.length > 0 ? `<div style="margin-bottom:0.75rem">
470
- <div style="font-weight:600;margin-bottom:0.5rem">✅ 产出 (${summary.commits.length} commits)</div>
471
- ${summary.commits.map(c => `<div style="margin-left:1rem;font-size:0.85rem;color:var(--green)">- ${c.message}</div>`).join('')}
472
- </div>` : ''}
473
- ${summary.filesChanged.length > 0 ? `<div style="margin-bottom:0.75rem">
474
- <div style="font-weight:600;margin-bottom:0.5rem">✅ 修改文件 (${summary.filesChanged.length} 个)</div>
475
- ${summary.filesChanged.slice(0, 10).map(f => `<div style="margin-left:1rem;font-size:0.85rem;color:var(--text-muted)">- ${f}</div>`).join('')}
476
- ${summary.filesChanged.length > 10 ? `<div style="margin-left:1rem;font-size:0.75rem;color:var(--text-dim)">... 还有 ${summary.filesChanged.length - 10} 个</div>` : ''}
477
- </div>` : ''}
478
- ${summary.unresolvedIssues.length > 0 ? `<div>
479
- <div style="font-weight:600;margin-bottom:0.5rem">⚠️ 未解决质量问题 (${summary.unresolvedIssues.length} 个)</div>
480
- ${summary.unresolvedIssues.map(q => `<div style="margin-left:1rem;font-size:0.85rem;color:var(--red)">- [${q.level}/${q.category}] ${q.message}</div>`).join('')}
481
- </div>` : '<div style="color:var(--green)">✅ 无未解决问题</div>'}
482
- </div>`;
483
-
484
- document.getElementById('session-detail').innerHTML = html;
485
- }
486
-
487
- let sessionData = null; // Store session data for task detail navigation
488
-
489
- async function loadSessionTasks(sessionId) {
490
- showPage('session-detail');
491
- document.getElementById('session-detail').innerHTML = '<div class="empty">Loading...</div>';
492
-
603
+ // Recent activity
493
604
  try {
494
- const res = await fetch(API + '/api/sessions/' + sessionId + '/detail');
495
- const data = await res.json();
496
- sessionData = data; // Store for later
497
-
498
- const shortId = data.session.session_id.slice(0, 8);
499
- const start = parseTime(data.session.start_time).toLocaleString();
500
- const end = parseTime(data.session.end_time).toLocaleString();
501
-
502
- let html = `
503
- <div class="card fade-in" style="margin-bottom:1.5rem">
504
- <h2 style="margin-bottom:0.5rem">会话 ${shortId}</h2>
505
- <div style="color:var(--text-muted)">${data.session.first_prompt || '(无提示词)'}</div>
506
- <div style="margin-top:0.5rem;font-size:0.8rem;color:var(--text-dim)">${start} ~ ${end} · ${data.tasks.length} tasks · ${data.session.event_count} events</div>
507
- </div>
508
- `;
509
-
510
- // Task list (clean, clickable)
511
- data.tasks.forEach((task, idx) => {
512
- const taskStart = parseTime(task.start_time).toLocaleTimeString();
513
- const taskEnd = task.end_time ? parseTime(task.end_time).toLocaleTimeString() : 'ongoing';
514
- const toolCount = task.events.filter(e => e.tool_name).length;
515
- const commitCount = task.summary.commits.length;
516
-
517
- html += `<div class="session-card fade-in" onclick="loadTaskDetail(${idx})" style="cursor:pointer">
518
- <div style="display:flex;justify-content:space-between;align-items:start">
519
- <div style="flex:1">
520
- <h3 style="margin-bottom:0.5rem">任务 ${idx + 1}: ${task.title}</h3>
521
- <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">${taskStart} ~ ${taskEnd}</div>
522
- <div style="font-size:0.75rem;color:var(--text-dim)">
523
- 📝 ${task.prompts.length} 次输入 ·
524
- 💉 ${task.injections.length} 次注入 ·
525
- 🔧 ${toolCount} 次工具调用 ·
526
- 📁 ${task.summary.filesChanged.length} 个文件
527
- ${commitCount > 0 ? ` · 📦 ${commitCount} commits` : ''}
528
- ${task.qualityIssues.length > 0 ? ` · ⚠️ ${task.qualityIssues.length} 个问题` : ''}
529
- </div>
605
+ const res = await fetch(API + '/api/events?limit=10');
606
+ const events = await res.json();
607
+ document.getElementById('dash-activity').innerHTML = events.length === 0
608
+ ? empty('No activity yet')
609
+ : events.map(e => `
610
+ <div class="activity-item">
611
+ <div class="activity-dot activity-dot-${hookColor(e.hook_type)}"></div>
612
+ <div class="activity-content">
613
+ <div class="activity-title">${e.hook_type} ${e.tool_name ? '· ' + e.tool_name : ''}</div>
614
+ <div class="activity-time">${fmtTime(e.timestamp)}</div>
530
615
  </div>
531
- <div style="font-size:1.5rem">→</div>
532
- </div>
533
- </div>`;
534
- });
535
-
536
- document.getElementById('session-detail').innerHTML = html;
537
- } catch (err) {
538
- document.getElementById('session-detail').innerHTML = '<div class="empty">Failed to load: ' + err.message + '</div>';
539
- }
616
+ </div>`).join('');
617
+ } catch {}
540
618
  }
541
619
 
542
- function loadTaskDetail(taskIdx) {
543
- if (!sessionData || !sessionData.tasks[taskIdx]) return;
544
-
545
- showPage('task-detail');
546
- const task = sessionData.tasks[taskIdx];
547
-
548
- document.getElementById('task-detail-title').textContent = `任务 ${taskIdx + 1}: ${task.title}`;
549
-
550
- const taskStart = parseTime(task.start_time).toLocaleString();
551
- const taskEnd = task.end_time ? parseTime(task.end_time).toLocaleString() : 'ongoing';
552
- const toolList = Object.entries(task.summary.toolUsage).map(([k, v]) => `${k}×${v}`).join(', ');
553
-
554
- let html = `
555
- <div class="card fade-in" style="margin-bottom:1.5rem">
556
- <div style="font-size:0.85rem;color:var(--text-dim)">${taskStart} ~ ${taskEnd}</div>
557
- <div style="margin-top:0.5rem;font-size:0.85rem">
558
- ${task.event_count} events · ${task.summary.filesChanged.length} files changed
559
- </div>
560
- </div>
561
- `;
562
-
563
- // User inputs
564
- html += `<div class="card fade-in" style="margin-bottom:1rem">
565
- <h3 style="margin-bottom:1rem">📝 用户输入 (${task.prompts.length} 次)</h3>
566
- ${task.prompts.map((p, pidx) => `
567
- <div style="margin-bottom:1rem;padding:0.75rem;background:var(--bg-secondary);border-radius:6px">
568
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">${parseTime(p.timestamp).toLocaleTimeString()}</div>
569
- <div style="white-space:pre-wrap">${p.content}</div>
570
- </div>
571
- `).join('')}
572
- </div>`;
573
-
574
- // Injections
575
- html += `<div class="card fade-in" style="margin-bottom:1rem">
576
- <h3 style="margin-bottom:1rem">💉 forge 注入 (${task.injections.length} 次)</h3>
577
- ${task.injections.map((inj, iidx) => `
578
- <div style="margin-bottom:1rem">
579
- <div style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem">
580
- ${parseTime(inj.timestamp).toLocaleTimeString()} - [${inj.source_handler}] ${inj.injection_type}
581
- </div>
582
- <pre style="font-size:0.75rem;background:var(--bg-secondary);padding:0.75rem;border-radius:6px;white-space:pre-wrap;max-height:400px;overflow-y:auto">${inj.content}</pre>
583
- </div>
584
- `).join('')}
585
- </div>`;
586
-
587
- // Tool executions
588
- const toolEvents = task.events.filter(e => e.tool_name);
589
- html += `<div class="card fade-in" style="margin-bottom:1rem">
590
- <h3 style="margin-bottom:0.5rem">⚙️ 工具执行 (${toolEvents.length} 次)</h3>
591
- <div style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem">统计: ${toolList || 'none'}</div>
592
- ${toolEvents.map((ev, eidx) => {
593
- const input = ev.tool_input || {};
594
- let summary = '';
595
- if (ev.tool_name === 'Bash') summary = (input.command || '').slice(0, 100);
596
- else if (ev.tool_name === 'Edit' || ev.tool_name === 'Write') summary = `file: ${input.file_path || ''}`;
597
- else if (ev.tool_name === 'Read') summary = `file: ${input.file_path || ''}`;
598
- else summary = JSON.stringify(input).slice(0, 100);
599
-
600
- return `<div style="margin-bottom:1rem;padding:0.75rem;background:var(--bg-secondary);border-radius:6px">
601
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem">
602
- ${parseTime(ev.timestamp).toLocaleTimeString()} - 🔧 ${ev.tool_name}
603
- </div>
604
- <div style="font-size:0.85rem;margin-bottom:0.5rem;font-family:monospace">${summary}</div>
605
- <details style="margin-top:0.5rem">
606
- <summary style="cursor:pointer;font-size:0.75rem;color:var(--accent)">查看完整 input/output</summary>
607
- <pre style="margin-top:0.5rem;font-size:0.7rem;background:var(--bg);padding:0.5rem;border-radius:4px;overflow-x:auto;max-height:300px;overflow-y:auto">${JSON.stringify({input: ev.tool_input, output: ev.tool_output}, null, 2)}</pre>
608
- </details>
609
- </div>`;
610
- }).join('')}
611
- </div>`;
612
-
613
- // Files changed
614
- if (task.summary.filesChanged.length > 0) {
615
- html += `<div class="card fade-in" style="margin-bottom:1rem">
616
- <h3 style="margin-bottom:1rem">📁 修改的文件 (${task.summary.filesChanged.length} 个)</h3>
617
- ${task.summary.filesChanged.map(f => `<div style="font-size:0.85rem;font-family:monospace;margin-bottom:0.25rem">- ${f}</div>`).join('')}
618
- </div>`;
619
- }
620
-
621
- // Quality issues
622
- if (task.qualityIssues.length > 0) {
623
- html += `<div class="card fade-in" style="margin-bottom:1rem;border-left:3px solid var(--yellow)">
624
- <h3 style="margin-bottom:1rem">⚠️ 质量问题 (${task.qualityIssues.length} 个)</h3>
625
- ${task.qualityIssues.map(q => `
626
- <div style="margin-bottom:0.75rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px">
627
- <div style="font-size:0.75rem;color:var(--red);font-weight:600">[${q.level}/${q.category}]</div>
628
- <div style="font-size:0.85rem;margin-top:0.25rem">${q.message}</div>
629
- </div>
630
- `).join('')}
631
- </div>`;
632
- }
633
-
634
- // Commits
635
- if (task.summary.commits.length > 0) {
636
- html += `<div class="card fade-in" style="border-left:3px solid var(--green)">
637
- <h3 style="margin-bottom:1rem">📦 产出 (${task.summary.commits.length} commits)</h3>
638
- ${task.summary.commits.map(c => `
639
- <div style="margin-bottom:0.75rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px">
640
- <div style="font-size:0.75rem;color:var(--text-dim)">${parseTime(c.timestamp).toLocaleTimeString()}</div>
641
- <div style="font-size:0.85rem;color:var(--green);margin-top:0.25rem">${c.message}</div>
642
- </div>
643
- `).join('')}
644
- </div>`;
645
- }
646
-
647
- document.getElementById('task-detail').innerHTML = html;
620
+ function hookColor(t) {
621
+ return { PreToolUse:'blue', PostToolUse:'green', UserPromptSubmit:'yellow', Stop:'red' }[t] || 'blue';
648
622
  }
649
623
 
650
- function backToSession() {
651
- if (currentSessionId) {
652
- loadSessionDetail(currentSessionId);
653
- } else {
654
- showPage('dashboard');
655
- }
624
+ function renderActivityChart(data) {
625
+ const ctx = document.getElementById('chart-activity');
626
+ if (!ctx) return;
627
+ if (charts.activity) charts.activity.destroy();
628
+ charts.activity = new Chart(ctx, {
629
+ type: 'line',
630
+ data: {
631
+ labels: data.map(d => d.date),
632
+ datasets: [{ label: 'Events', data: data.map(d => d.count), borderColor: '#4f46e5', backgroundColor: 'rgba(79,70,229,0.08)', tension: 0.3, fill: true }]
633
+ },
634
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { ticks: { color: '#9ca3af' }, grid: { color: '#e4e7ec' } }, x: { ticks: { color: '#9ca3af' }, grid: { color: '#e4e7ec' } } } }
635
+ });
656
636
  }
657
637
 
658
- loadStatus();
638
+ function renderToolChart(data) {
639
+ const ctx = document.getElementById('chart-tools');
640
+ if (!ctx) return;
641
+ if (charts.tools) charts.tools.destroy();
642
+ const labels = Object.keys(data).slice(0, 8);
643
+ const values = labels.map(k => data[k]);
644
+ charts.tools = new Chart(ctx, {
645
+ type: 'doughnut',
646
+ data: { labels, datasets: [{ data: values, backgroundColor: ['#4f46e5','#16a34a','#d97706','#dc2626','#2563eb','#7c3aed','#0891b2','#db2777'] }] },
647
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#4b5563', boxWidth: 12, font: { size: 11 } } } } }
648
+ });
649
+ }
659
650
 
660
- let liveSource = null;
651
+ // === Live ===
661
652
  function toggleLive() {
662
653
  if (liveSource) {
663
654
  liveSource.close(); liveSource = null;
664
655
  document.getElementById('live-status').textContent = 'Disconnected';
665
656
  document.getElementById('live-status').className = 'badge badge-warn';
666
- document.getElementById('live-toggle').textContent = 'Connect';
657
+ document.getElementById('live-btn').textContent = 'Connect';
658
+ document.getElementById('live-btn').className = 'btn btn-primary';
667
659
  return;
668
660
  }
669
661
  liveSource = new EventSource(API + '/api/events/stream');
670
- document.getElementById('live-status').textContent = 'Connected';
671
- document.getElementById('live-status').className = 'badge badge-allow';
672
- document.getElementById('live-toggle').textContent = 'Disconnect';
662
+ document.getElementById('live-status').textContent = 'Live';
663
+ document.getElementById('live-status').className = 'badge badge-live';
664
+ document.getElementById('live-btn').textContent = 'Disconnect';
665
+ document.getElementById('live-btn').className = 'btn';
673
666
  liveSource.onmessage = function(ev) {
674
667
  try {
675
668
  const e = JSON.parse(ev.data);
676
669
  if (e.type === 'connected') return;
677
- const container = document.getElementById('live-events');
678
- const time = parseTime(e.timestamp).toLocaleTimeString();
679
- const tool = e.tool_name || '';
680
- const detail = e.user_prompt ? e.user_prompt.slice(0,80) : (e.tool_input?.command || e.tool_input?.file_path || '');
670
+ const log = document.getElementById('live-log');
681
671
  const line = document.createElement('div');
682
- line.style.cssText = 'padding:2px 0;border-bottom:1px solid #334155';
683
- const badge = e.hook_type === 'PreToolUse' ? 'info' : e.hook_type === 'Stop' ? 'warn' : 'allow';
684
- line.innerHTML = '<span style="color:#64748b">'+time+'</span> <span class="badge badge-'+badge+'">'+e.hook_type+'</span> <span style="color:#93c5fd">'+tool+'</span> '+String(detail).slice(0,60);
685
- container.appendChild(line);
686
- container.scrollTop = container.scrollHeight;
687
- } catch(x) {}
672
+ line.className = 'live-log-line';
673
+ const tool = e.tool_name ? `<span style="color:#93c5fd">${e.tool_name}</span>` : '';
674
+ const detail = (e.user_prompt || e.tool_input?.command || e.tool_input?.file_path || '').toString().slice(0, 60);
675
+ line.innerHTML = `<span style="color:#64748b">${fmtTime(e.timestamp)}</span> ${badgeHook(e.hook_type)} ${tool} <span style="color:#cbd5e1">${detail}</span>`;
676
+ log.appendChild(line);
677
+ log.scrollTop = log.scrollHeight;
678
+ } catch {}
688
679
  };
689
680
  liveSource.onerror = function() {
690
681
  document.getElementById('live-status').textContent = 'Error';
691
682
  document.getElementById('live-status').className = 'badge badge-block';
692
683
  };
693
684
  }
694
- function clearLive() { document.getElementById('live-events').innerHTML = ''; }
695
-
696
- // Initialize dashboard on page load
697
- loadStatus();
698
- loadSessions();
699
-
700
- // ========== Stats Page with Chart.js ==========
701
- let statsCharts = {};
702
-
703
- async function loadStats() {
704
- try {
705
- const res = await fetch(API + '/api/stats');
706
- const stats = await res.json();
707
-
708
- // Tool Usage Chart (Doughnut)
709
- const toolCtx = document.getElementById('chart-tool-usage');
710
- if (statsCharts.toolUsage) statsCharts.toolUsage.destroy();
711
- statsCharts.toolUsage = new Chart(toolCtx, {
712
- type: 'doughnut',
713
- data: {
714
- labels: Object.keys(stats.toolUsage),
715
- datasets: [{
716
- data: Object.values(stats.toolUsage),
717
- backgroundColor: ['#818cf8', '#34d399', '#fbbf24', '#f87171', '#60a5fa', '#a78bfa']
718
- }]
719
- },
720
- options: {
721
- responsive: true,
722
- plugins: {
723
- legend: { labels: { color: '#e2e8f0' } }
724
- }
725
- }
726
- });
727
-
728
- // Decision Levels Chart (Bar)
729
- const decisionCtx = document.getElementById('chart-decision-levels');
730
- if (statsCharts.decisionLevels) statsCharts.decisionLevels.destroy();
731
- statsCharts.decisionLevels = new Chart(decisionCtx, {
732
- type: 'bar',
733
- data: {
734
- labels: Object.keys(stats.decisionLevels),
735
- datasets: [{
736
- label: 'Count',
737
- data: Object.values(stats.decisionLevels),
738
- backgroundColor: '#818cf8'
739
- }]
740
- },
741
- options: {
742
- responsive: true,
743
- plugins: {
744
- legend: { labels: { color: '#e2e8f0' } }
745
- },
746
- scales: {
747
- y: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
748
- x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } }
749
- }
750
- }
751
- });
752
-
753
- // Daily Activity Chart (Line)
754
- const activityCtx = document.getElementById('chart-daily-activity');
755
- if (statsCharts.dailyActivity) statsCharts.dailyActivity.destroy();
756
- statsCharts.dailyActivity = new Chart(activityCtx, {
757
- type: 'line',
758
- data: {
759
- labels: stats.dailyActivity.map(d => d.date),
760
- datasets: [{
761
- label: 'Events',
762
- data: stats.dailyActivity.map(d => d.count),
763
- borderColor: '#818cf8',
764
- backgroundColor: 'rgba(129,140,248,0.1)',
765
- tension: 0.3
766
- }]
767
- },
768
- options: {
769
- responsive: true,
770
- plugins: {
771
- legend: { labels: { color: '#e2e8f0' } }
772
- },
773
- scales: {
774
- y: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
775
- x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } }
776
- }
777
- }
778
- });
779
- } catch (err) {
780
- console.error('Failed to load stats:', err);
781
- }
782
- }
783
-
784
- // ========== Search and Filter Functions ==========
785
- let allEvents = [];
786
- let allRules = [];
787
- let allInjections = [];
788
-
789
- async function loadEvents() {
790
- try {
791
- const res = await fetch(API + '/api/events?limit=50');
792
- allEvents = await res.json();
793
- renderEvents(allEvents);
794
- } catch {
795
- document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="empty">Failed to load</td></tr>';
796
- }
797
- }
798
-
799
- function renderEvents(events) {
800
- if (events.length === 0) {
801
- document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="empty">No events</td></tr>';
802
- return;
803
- }
804
- document.getElementById('events-body').innerHTML = events.map(e => {
805
- const time = parseTime(e.timestamp).toLocaleTimeString();
806
- const badge = e.hook_type === 'PreToolUse' ? 'info' : e.hook_type === 'Stop' ? 'warn' : 'allow';
807
- return `<tr>
808
- <td>${time}</td>
809
- <td><span class="badge badge-${badge}">${e.hook_type}</span></td>
810
- <td>${e.tool_name || '—'}</td>
811
- <td>${(e.project_path || '').split('/').pop()}</td>
812
- </tr>`;
813
- }).join('');
814
- }
815
-
816
- function filterEvents() {
817
- const query = document.getElementById('events-search').value.toLowerCase();
818
- const filtered = allEvents.filter(e =>
819
- (e.hook_type || '').toLowerCase().includes(query) ||
820
- (e.tool_name || '').toLowerCase().includes(query) ||
821
- (e.project_path || '').toLowerCase().includes(query)
822
- );
823
- renderEvents(filtered);
824
- }
825
685
 
686
+ // === Init ===
687
+ nav('dashboard');
688
+ </script>
689
+ </body>
690
+ </html>
826
691
  async function loadRules() {
692
+ document.getElementById('rules-list').innerHTML = loading();
827
693
  try {
828
694
  const res = await fetch(API + '/api/rules');
829
- const rulesData = await res.json();
695
+ const data = await res.json();
830
696
  allRules = [];
831
-
832
- Object.entries(rulesData).forEach(([id, conv]) => {
833
- (conv.rules || []).forEach(rule => {
834
- allRules.push({ ...rule, conventionId: id, conventionName: conv.name });
835
- });
697
+ Object.entries(data).forEach(([id, conv]) => {
698
+ (conv.rules || []).forEach(r => allRules.push({ ...r, _convId: id, _convName: conv.name }));
836
699
  });
837
-
838
700
  renderRules(allRules);
839
701
  } catch {
840
- document.getElementById('rules-list').innerHTML = '<div class="empty">Failed to load</div>';
702
+ document.getElementById('rules-list').innerHTML = empty('Failed to load rules');
841
703
  }
842
704
  }
843
705
 
844
- function renderRules(rules) {
845
- if (rules.length === 0) {
846
- document.getElementById('rules-list').innerHTML = '<div class="empty">No rules loaded</div>';
847
- return;
848
- }
849
-
850
- document.getElementById('rules-list').innerHTML = rules.map((rule, idx) => {
851
- const tags = (rule.tags || []).map(t => `<span class="tag">${t}</span>`).join('');
852
- return `<div class="rule-card fade-in" onclick="loadRuleDetail(${idx})">
853
- <div style="display:flex;justify-content:space-between;align-items:start">
854
- <div style="flex:1">
855
- <div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.25rem">${rule.conventionName || rule.conventionId}</div>
856
- <h3 style="margin-bottom:0.5rem">${rule.name || 'Unnamed Rule'}</h3>
857
- <div style="font-size:0.85rem;color:var(--text-muted);margin-bottom:0.5rem">${(rule.description || '').slice(0, 100)}</div>
858
- <div style="margin-top:0.5rem">${tags}</div>
706
+ function renderRules(list) {
707
+ document.getElementById('rules-list').innerHTML = list.length === 0
708
+ ? empty('No rules found')
709
+ : list.map(r => `<div class="list-item fade-in" onclick="openRuleDrawer(${JSON.stringify(r).replace(/"/g,'&quot;')})">
710
+ <div style="flex:1;min-width:0">
711
+ <div style="font-size:0.875rem;font-weight:500;margin-bottom:2px">${r.name || r.id || 'Unnamed'}</div>
712
+ <div style="font-size:0.75rem;color:var(--text-dim)">${r._convName || r._convId} ${r.tags ? '· ' + r.tags.join(', ') : ''}</div>
859
713
  </div>
860
- <span style="color:var(--accent);font-size:1.5rem">→</span>
861
- </div>
862
- </div>`;
863
- }).join('');
714
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-dim);flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg>
715
+ </div>`).join('');
864
716
  }
865
717
 
866
718
  function filterRules() {
867
- const query = document.getElementById('rules-search').value.toLowerCase();
868
- const filtered = allRules.filter(r =>
869
- (r.name || '').toLowerCase().includes(query) ||
870
- (r.description || '').toLowerCase().includes(query) ||
871
- (r.category || '').toLowerCase().includes(query) ||
872
- (r.tags || []).some(t => t.toLowerCase().includes(query))
873
- );
874
- renderRules(filtered);
719
+ const q = document.getElementById('rules-search').value.toLowerCase();
720
+ renderRules(allRules.filter(r =>
721
+ (r.name||'').toLowerCase().includes(q) ||
722
+ (r.description||'').toLowerCase().includes(q) ||
723
+ (r._convName||'').toLowerCase().includes(q) ||
724
+ (r.tags||[]).some(t => t.toLowerCase().includes(q))
725
+ ));
875
726
  }
876
727
 
728
+ function openRuleDrawer(r) {
729
+ const tags = (r.tags||[]).map(t => `<span class="tag">${t}</span>`).join('');
730
+ const html = `
731
+ <div class="detail-section"><div class="detail-label">Convention</div><div class="detail-value">${r._convName || r._convId}</div></div>
732
+ <div class="detail-section"><div class="detail-label">Name</div><div class="detail-value" style="font-weight:600">${r.name || '—'}</div></div>
733
+ ${tags ? `<div class="detail-section"><div class="detail-label">Tags</div><div class="detail-value">${tags}</div></div>` : ''}
734
+ <div class="detail-section"><div class="detail-label">Description</div><div class="detail-value">${r.description || '—'}</div></div>
735
+ ${r.when ? `<div class="detail-section"><div class="detail-label">Condition</div><pre class="detail-code">${r.when}</pre></div>` : ''}
736
+ ${r.message ? `<div class="detail-section"><div class="detail-label">Message</div><div class="detail-value">${r.message}</div></div>` : ''}
737
+ ${r.operator_guidance ? `<div class="detail-section"><div class="detail-label">Guidance</div><div class="detail-value" style="white-space:pre-wrap">${r.operator_guidance}</div></div>` : ''}
738
+ ${r.doc_ref ? `<div class="detail-section"><div class="detail-label">Docs</div><div class="detail-value"><a href="${r.doc_ref}" target="_blank" style="color:var(--primary)">${r.doc_ref}</a></div></div>` : ''}
739
+ `;
740
+ openDrawer(r.name || 'Rule', html);
741
+ }
877
742
  async function loadInjections() {
743
+ document.getElementById('inj-list').innerHTML = loading();
878
744
  try {
879
- const session = document.getElementById('inj-session').value;
880
- const handler = document.getElementById('inj-handler').value;
881
- let url = API + '/api/injections?limit=50';
882
- if (session) url += '&session=' + session;
883
- if (handler) url += '&handler=' + handler;
884
- const res = await fetch(url);
745
+ const res = await fetch(API + '/api/injections?limit=100');
885
746
  allInjections = await res.json();
886
-
887
- const sessRes = await fetch(API + '/api/sessions?limit=10');
888
- const sessions = await sessRes.json();
889
- const sessSelect = document.getElementById('inj-session');
890
- if (sessSelect.options.length === 1) {
891
- sessions.forEach(s => {
892
- const opt = document.createElement('option');
893
- opt.value = s.session_id;
894
- opt.textContent = s.session_id.slice(0, 8);
895
- sessSelect.appendChild(opt);
896
- });
897
- }
898
-
899
747
  renderInjections(allInjections);
900
748
  } catch {
901
- document.getElementById('injections-list').innerHTML = '<div class="empty">Failed to load</div>';
749
+ document.getElementById('inj-list').innerHTML = empty('Failed to load');
902
750
  }
903
751
  }
904
752
 
905
- function renderInjections(injections) {
906
- if (injections.length === 0) {
907
- document.getElementById('injections-list').innerHTML = '<div class="empty">No injections found</div>';
908
- return;
909
- }
910
-
911
- document.getElementById('injections-list').innerHTML = injections.map(inj => {
912
- const time = parseTime(inj.timestamp).toLocaleString();
913
- const shortSession = inj.session_id.slice(0, 8);
914
- const preview = inj.content.slice(0, 150);
915
- return `<div class="inj-card fade-in">
916
- <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem">
917
- <span class="badge badge-info">${inj.injection_type}</span>
918
- <span style="font-size:0.75rem;color:var(--text-dim)">${time}</span>
919
- </div>
920
- <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">
921
- Session: ${shortSession} · Handler: ${inj.source_handler}
922
- </div>
923
- <details>
924
- <summary style="cursor:pointer;font-size:0.85rem;color:var(--accent)">Preview: ${preview}...</summary>
925
- <pre style="margin-top:0.5rem;font-size:0.75rem;background:var(--bg-secondary);padding:0.75rem;border-radius:6px;white-space:pre-wrap;max-height:400px;overflow-y:auto">${inj.content}</pre>
926
- </details>
927
- </div>`;
928
- }).join('');
753
+ function renderInjections(list) {
754
+ document.getElementById('inj-list').innerHTML = list.length === 0
755
+ ? empty('No injections found')
756
+ : list.map(inj => `<div class="list-item fade-in" onclick="openInjDrawer(${JSON.stringify(inj).replace(/"/g,'&quot;')})">
757
+ <div style="flex:1;min-width:0">
758
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:2px">
759
+ <span class="badge badge-info">${inj.injection_type}</span>
760
+ <span style="font-size:0.75rem;color:var(--text-dim)">${inj.source_handler}</span>
761
+ </div>
762
+ <div style="font-size:0.8rem;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${inj.content.slice(0,80)}</div>
763
+ <div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">${fmt(inj.timestamp)} · ${inj.session_id.slice(0,8)}</div>
764
+ </div>
765
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-dim);flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg>
766
+ </div>`).join('');
929
767
  }
930
768
 
931
769
  function filterInjections() {
932
- const query = document.getElementById('injections-search').value.toLowerCase();
933
- const filtered = allInjections.filter(inj =>
934
- (inj.injection_type || '').toLowerCase().includes(query) ||
935
- (inj.content || '').toLowerCase().includes(query) ||
936
- (inj.source_handler || '').toLowerCase().includes(query)
937
- );
938
- renderInjections(filtered);
770
+ const q = document.getElementById('inj-search').value.toLowerCase();
771
+ const h = document.getElementById('inj-handler').value;
772
+ renderInjections(allInjections.filter(inj =>
773
+ (!h || inj.source_handler === h) &&
774
+ (!q || inj.content.toLowerCase().includes(q) || inj.injection_type.toLowerCase().includes(q))
775
+ ));
939
776
  }
940
777
 
941
- // ========== Rule Detail Page ==========
942
- function loadRuleDetail(ruleIdx) {
943
- if (!allRules[ruleIdx]) return;
944
-
945
- showPage('rule-detail');
946
- const rule = allRules[ruleIdx];
778
+ function openInjDrawer(inj) {
779
+ const html = `
780
+ <div class="detail-section"><div class="detail-label">Type</div><div class="detail-value"><span class="badge badge-info">${inj.injection_type}</span></div></div>
781
+ <div class="detail-section"><div class="detail-label">Handler</div><div class="detail-value">${inj.source_handler}</div></div>
782
+ <div class="detail-section"><div class="detail-label">Time</div><div class="detail-value">${fmt(inj.timestamp)}</div></div>
783
+ <div class="detail-section"><div class="detail-label">Session</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">${inj.session_id}</div></div>
784
+ <div class="detail-section"><div class="detail-label">Content</div><pre class="detail-code">${inj.content}</pre></div>
785
+ `;
786
+ openDrawer(inj.injection_type, html);
787
+ }
788
+ async function loadEvents() {
789
+ document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="loading">Loading...</td></tr>';
790
+ try {
791
+ const res = await fetch(API + '/api/events?limit=100');
792
+ allEvents = await res.json();
793
+ renderEvents(allEvents);
794
+ } catch {
795
+ document.getElementById('events-body').innerHTML = '<tr><td colspan="4">' + empty('Failed to load') + '</td></tr>';
796
+ }
797
+ }
947
798
 
948
- document.getElementById('rule-detail-title').textContent = rule.name || 'Unnamed Rule';
799
+ function renderEvents(list) {
800
+ document.getElementById('events-body').innerHTML = list.length === 0
801
+ ? '<tr><td colspan="4">' + empty('No events') + '</td></tr>'
802
+ : list.map(e => `<tr onclick="openEventDrawer(${JSON.stringify(e).replace(/"/g,'&quot;')})">
803
+ <td style="color:var(--text-dim);font-size:0.8rem">${fmtTime(e.timestamp)}</td>
804
+ <td>${badgeHook(e.hook_type)}</td>
805
+ <td style="font-family:monospace;font-size:0.8rem">${e.tool_name || '—'}</td>
806
+ <td style="color:var(--text-dim);font-size:0.8rem">${(e.project_path||'').split('/').pop() || '—'}</td>
807
+ </tr>`).join('');
808
+ }
949
809
 
950
- const tags = (rule.tags || []).map(t => `<span class="tag">${t}</span>`).join('');
951
- const docRef = rule.doc_ref ? `<a href="${rule.doc_ref}" target="_blank" style="color:var(--accent)">${rule.doc_ref}</a>` : 'N/A';
810
+ function filterEvents() {
811
+ const q = document.getElementById('events-search').value.toLowerCase();
812
+ const t = document.getElementById('events-type').value;
813
+ renderEvents(allEvents.filter(e =>
814
+ (!t || e.hook_type === t) &&
815
+ (!q || (e.hook_type||'').toLowerCase().includes(q) || (e.tool_name||'').toLowerCase().includes(q) || (e.project_path||'').toLowerCase().includes(q))
816
+ ));
817
+ }
952
818
 
953
- let html = `
954
- <div class="card fade-in" style="margin-bottom:1.5rem">
955
- <div style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Convention: ${rule.conventionName || rule.conventionId}</div>
956
- <h2 style="margin-bottom:1rem">${rule.name || 'Unnamed Rule'}</h2>
957
- <div style="margin-bottom:1rem">${tags}</div>
958
- <div style="font-size:0.95rem;color:var(--text-muted);line-height:1.6">${rule.description || 'No description'}</div>
959
- </div>
819
+ function openEventDrawer(e) {
820
+ const html = `
821
+ <div class="detail-section"><div class="detail-label">Type</div><div class="detail-value">${badgeHook(e.hook_type)}</div></div>
822
+ <div class="detail-section"><div class="detail-label">Time</div><div class="detail-value">${fmt(e.timestamp)}</div></div>
823
+ <div class="detail-section"><div class="detail-label">Tool</div><div class="detail-value" style="font-family:monospace">${e.tool_name || '—'}</div></div>
824
+ <div class="detail-section"><div class="detail-label">Project</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">${e.project_path || ''}</div></div>
825
+ <div class="detail-section"><div class="detail-label">Session</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">${e.session_id || '—'}</div></div>
826
+ ${e.tool_input ? `<div class="detail-section"><div class="detail-label">Input</div><pre class="detail-code">${JSON.stringify(e.tool_input, null, 2)}</pre></div>` : ''}
960
827
  `;
961
-
962
- html += `<div class="card fade-in" style="margin-bottom:1rem">
963
- <h3 style="margin-bottom:1rem">Rule Details</h3>
964
- <div style="margin-bottom:0.75rem">
965
- <div style="font-size:0.85rem;font-weight:600;color:var(--text-muted)">Category</div>
966
- <div style="font-size:0.95rem">${rule.category || 'N/A'}</div>
967
- </div>
968
- <div style="margin-bottom:0.75rem">
969
- <div style="font-size:0.85rem;font-weight:600;color:var(--text-muted)">When Condition</div>
970
- <pre style="font-size:0.85rem;background:var(--bg-secondary);padding:0.75rem;border-radius:6px;overflow-x:auto">${rule.when || 'N/A'}</pre>
971
- </div>
972
- <div style="margin-bottom:0.75rem">
973
- <div style="font-size:0.85rem;font-weight:600;color:var(--text-muted)">Action</div>
974
- <div style="font-size:0.95rem">${rule.action || 'N/A'}</div>
975
- </div>
976
- <div style="margin-bottom:0.75rem">
977
- <div style="font-size:0.85rem;font-weight:600;color:var(--text-muted)">Message</div>
978
- <div style="font-size:0.95rem">${rule.message || 'N/A'}</div>
979
- </div>
980
- <div>
981
- <div style="font-size:0.85rem;font-weight:600;color:var(--text-muted)">Documentation</div>
982
- <div style="font-size:0.95rem">${docRef}</div>
828
+ openDrawer(e.hook_type + ' · ' + (e.tool_name || 'event'), html);
829
+ }
830
+ function sessionListItem(s) {
831
+ const prompt = (s.first_prompt || '(no prompt)').slice(0, 60);
832
+ return `<div class="list-item fade-in" onclick="openSessionDrawer('${s.session_id}')">
833
+ <div style="flex:1;min-width:0">
834
+ <div style="font-size:0.875rem;font-weight:500;margin-bottom:2px">${prompt}</div>
835
+ <div style="font-size:0.75rem;color:var(--text-dim)">${fmt(s.start_time)} · ${s.event_count} events · ${s.session_id.slice(0,8)}</div>
983
836
  </div>
837
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-dim);flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg>
984
838
  </div>`;
839
+ }
985
840
 
986
- if (rule.operator_guidance) {
987
- html += `<div class="card fade-in" style="border-left:3px solid var(--accent)">
988
- <h3 style="margin-bottom:1rem">Operator Guidance</h3>
989
- <div style="font-size:0.95rem;color:var(--text-muted);line-height:1.6;white-space:pre-wrap">${rule.operator_guidance}</div>
990
- </div>`;
841
+ async function loadSessions() {
842
+ document.getElementById('sessions-list').innerHTML = loading();
843
+ try {
844
+ const res = await fetch(API + '/api/sessions?limit=50');
845
+ allSessions = await res.json();
846
+ renderSessions(allSessions);
847
+ } catch {
848
+ document.getElementById('sessions-list').innerHTML = empty('Failed to load sessions');
991
849
  }
850
+ }
992
851
 
993
- document.getElementById('rule-detail').innerHTML = html;
852
+ function renderSessions(list) {
853
+ document.getElementById('sessions-list').innerHTML = list.length === 0
854
+ ? empty('No sessions found')
855
+ : list.map(s => sessionListItem(s)).join('');
994
856
  }
995
857
 
996
- </script>
997
- </body>
998
- </html>
858
+ function filterSessions() {
859
+ const q = document.getElementById('sessions-search').value.toLowerCase();
860
+ renderSessions(allSessions.filter(s =>
861
+ (s.first_prompt || '').toLowerCase().includes(q) ||
862
+ s.session_id.toLowerCase().includes(q)
863
+ ));
864
+ }
865
+
866
+ async function openSessionDrawer(sessionId) {
867
+ openDrawer('Session Detail', loading());
868
+ try {
869
+ const res = await fetch(API + '/api/sessions/' + sessionId + '/detail');
870
+ const data = await res.json();
871
+ const s = data.session;
872
+ let html = `
873
+ <div class="detail-section">
874
+ <div class="detail-label">Session ID</div>
875
+ <div class="detail-value" style="font-family:monospace;font-size:0.8rem">${s.session_id}</div>
876
+ </div>
877
+ <div class="detail-section">
878
+ <div class="detail-label">Time</div>
879
+ <div class="detail-value">${fmt(s.start_time)} → ${fmt(s.end_time)}</div>
880
+ </div>
881
+ <div class="detail-section">
882
+ <div class="detail-label">First Prompt</div>
883
+ <div class="detail-value">${s.first_prompt || '—'}</div>
884
+ </div>
885
+ <div class="detail-section">
886
+ <div class="detail-label">Tasks (${data.tasks.length})</div>`;
887
+ data.tasks.forEach((t, i) => {
888
+ const tools = t.events.filter(e => e.tool_name).length;
889
+ html += `<div style="padding:0.75rem;background:var(--bg-secondary);border-radius:var(--radius-sm);margin-bottom:0.5rem;border:1px solid var(--border)">
890
+ <div style="font-weight:500;font-size:0.875rem;margin-bottom:4px">Task ${i+1}: ${t.title}</div>
891
+ <div style="font-size:0.75rem;color:var(--text-dim)">${fmtTime(t.start_time)} · ${tools} tools · ${t.summary.filesChanged.length} files</div>
892
+ ${t.summary.commits.length > 0 ? `<div style="font-size:0.75rem;color:var(--green);margin-top:4px">✓ ${t.summary.commits.length} commit(s)</div>` : ''}
893
+ </div>`;
894
+ });
895
+ html += `</div>`;
896
+ openDrawer('Session · ' + s.session_id.slice(0,8), html);
897
+ } catch(e) {
898
+ openDrawer('Session Detail', empty('Failed to load: ' + e.message));
899
+ }
900
+ }