@winspan/claude-forge 8.11.1 → 8.13.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.
@@ -3,996 +3,964 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Claude Forge Dashboard</title>
6
+ <title>Claude Forge 管理后台</title>
7
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">总览</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
+ 仪表盘
340
+ </a>
341
+ <div class="nav-section-title">活动</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
+ 会话
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
+ 事件
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
+ 注入
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
+ 实时
357
+ </a>
358
+ <div class="nav-section-title">配置</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
+ 规则
362
+ </a>
363
+ </aside>
364
+
365
+ <!-- Main -->
366
+ <main class="main">
367
+ <div class="topbar">
368
+ <div class="page-title" id="topbar-title">仪表盘</div>
369
+ <div class="actions">
370
+ <span id="daemon-status" class="badge badge-allow" style="font-size:0.75rem">检查中...</span>
371
+ <button class="btn" onclick="refreshPage()">↻ 刷新</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 天活动</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">工具使用分布</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">最近会话</span>
397
+ <a onclick="nav('sessions')" style="font-size:0.8rem;color:var(--primary);cursor:pointer">查看全部 →</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">最近活动</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="搜索会话..." 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
-
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
419
 
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="按类型、工具、项目搜索..." oninput="filterEvents()">
424
+ <select class="btn" id="events-type" onchange="filterEvents()">
425
+ <option value="">全部类型</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>时间</th><th>类型</th><th>工具</th><th>项目</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="搜索注入内容..." oninput="filterInjections()">
444
+ <select class="btn" id="inj-handler" onchange="filterInjections()">
445
+ <option value="">全部 Handler</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">未连接</span>
460
+ <button class="btn btn-primary" id="live-btn" onclick="toggleLive()">连接</button>
461
+ <button class="btn" onclick="document.getElementById('live-log').innerHTML=''">清空</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="搜索规则..." 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">详情</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();
213
- }
214
-
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>'; }
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:'仪表盘', sessions:'会话', events:'事件', injections:'注入', live:'实时', 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();
229
518
  }
230
519
 
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>'; }
520
+ function refreshPage() {
521
+ const active = document.querySelector('.page.active');
522
+ if (active) nav(active.id.replace('page-', ''));
256
523
  }
257
524
 
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>'; }
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');
277
531
  }
278
-
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>'; }
532
+ function closeDrawer() {
533
+ document.getElementById('drawer').classList.remove('active');
534
+ document.getElementById('drawer-overlay').classList.remove('active');
298
535
  }
299
536
 
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>'; }
347
- }
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
- }
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' });
359
542
  }
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
- }
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' });
376
547
  }
377
-
378
- function toggleTimelineView() {
379
- currentView = currentView === 'timeline' ? 'tasks' : 'timeline';
380
- document.getElementById('current-view-label').textContent = currentView === 'timeline' ? '时间线视图' : '任务视图';
381
- if (currentSessionId) loadSessionDetail(currentSessionId);
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>`;
382
551
  }
383
-
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
-
390
- 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;
401
-
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>';
405
- }
552
+ function empty(msg) {
553
+ return `<div class="empty"><div class="empty-icon">📭</div><div class="empty-text">${msg}</div></div>`;
406
554
  }
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
-
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>`;
464
- }
465
-
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;
555
+ function loading() {
556
+ return `<div class="loading">加载中...</div>`;
485
557
  }
486
558
 
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
-
559
+ // === Dashboard ===
560
+ async function loadDashboard() {
561
+ document.getElementById('dash-stats').innerHTML = loading();
493
562
  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>
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 + ' 秒' : Math.floor(uptime/60) + ' 分钟';
573
+ const mem = Math.round(status.memory.heapUsed / 1024 / 1024);
574
+ document.getElementById('dash-stats').innerHTML = `
575
+ <div class="stat-card"><div class="label">进程 ID</div><div class="value">${status.pid}</div></div>
576
+ <div class="stat-card"><div class="label">运行时长</div><div class="value">${uptimeStr}</div></div>
577
+ <div class="stat-card"><div class="label">内存占用</div><div class="value">${mem}MB</div></div>
578
+ <div class="stat-card"><div class="label">事件总数</div><div class="value">${status.eventCount}</div></div>
508
579
  `;
580
+ document.getElementById('daemon-status').textContent = '运行中';
581
+ document.getElementById('daemon-status').className = 'badge badge-allow';
509
582
 
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>
530
- </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
- }
540
- }
541
-
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>`;
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">状态</div><div class="value" style="font-size:1rem;color:var(--red)">离线</div></div>`;
588
+ document.getElementById('daemon-status').textContent = '离线';
589
+ document.getElementById('daemon-status').className = 'badge badge-block';
619
590
  }
620
591
 
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>`;
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('暂无会话')
598
+ : sessions.map(s => sessionListItem(s)).join('');
599
+ } catch {
600
+ document.getElementById('dash-sessions').innerHTML = empty('加载失败');
632
601
  }
633
602
 
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
- }
603
+ // Recent activity
604
+ try {
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('暂无活动')
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>
615
+ </div>
616
+ </div>`).join('');
617
+ } catch {}
618
+ }
646
619
 
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: '事件数', 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
- document.getElementById('live-status').textContent = 'Disconnected';
655
+ document.getElementById('live-status').textContent = '未连接';
665
656
  document.getElementById('live-status').className = 'badge badge-warn';
666
- document.getElementById('live-toggle').textContent = 'Connect';
657
+ document.getElementById('live-btn').textContent = '连接';
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 = '实时中';
663
+ document.getElementById('live-status').className = 'badge badge-live';
664
+ document.getElementById('live-btn').textContent = '断开连接';
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
- document.getElementById('live-status').textContent = 'Error';
681
+ document.getElementById('live-status').textContent = '连接错误';
691
682
  document.getElementById('live-status').className = 'badge badge-block';
692
683
  };
693
684
  }
694
- function clearLive() { document.getElementById('live-events').innerHTML = ''; }
695
685
 
696
- // Initialize dashboard on page load
697
- loadStatus();
698
- loadSessions();
686
+ // === Sessions ===
687
+ function sessionListItem(s) {
688
+ const prompt = (s.first_prompt || '(无提示词)').slice(0, 60);
689
+ return `<div class="list-item fade-in" onclick="openSessionDrawer('${s.session_id}')">
690
+ <div style="flex:1;min-width:0">
691
+ <div style="font-size:0.875rem;font-weight:500;margin-bottom:2px">${prompt}</div>
692
+ <div style="font-size:0.75rem;color:var(--text-dim)">${fmt(s.start_time)} · ${s.event_count} 个事件 · ${s.session_id.slice(0,8)}</div>
693
+ </div>
694
+ <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>
695
+ </div>`;
696
+ }
697
+
698
+ async function loadSessions() {
699
+ document.getElementById('sessions-list').innerHTML = loading();
700
+ try {
701
+ const res = await fetch(API + '/api/sessions?limit=50');
702
+ allSessions = await res.json();
703
+ renderSessions(allSessions);
704
+ } catch {
705
+ document.getElementById('sessions-list').innerHTML = empty('加载会话失败');
706
+ }
707
+ }
708
+
709
+ function renderSessions(list) {
710
+ document.getElementById('sessions-list').innerHTML = list.length === 0
711
+ ? empty('未找到会话')
712
+ : list.map(s => sessionListItem(s)).join('');
713
+ }
714
+
715
+ function filterSessions() {
716
+ const q = document.getElementById('sessions-search').value.toLowerCase();
717
+ renderSessions(allSessions.filter(s =>
718
+ (s.first_prompt || '').toLowerCase().includes(q) ||
719
+ s.session_id.toLowerCase().includes(q)
720
+ ));
721
+ }
699
722
 
700
- // ========== Stats Page with Chart.js ==========
701
- let statsCharts = {};
723
+ let currentSessionData = null;
702
724
 
703
- async function loadStats() {
725
+ async function openSessionDrawer(sessionId) {
726
+ openDrawer('会话详情', loading());
704
727
  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
- }
728
+ const res = await fetch(API + '/api/sessions/' + sessionId + '/detail');
729
+ const data = await res.json();
730
+ currentSessionData = data;
731
+ const s = data.session;
732
+ let html = '<div class="detail-section"><div class="detail-label">会话 ID</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">' + s.session_id + '</div></div>';
733
+ html += '<div class="detail-section"><div class="detail-label">时间</div><div class="detail-value">' + fmt(s.start_time) + ' → ' + fmt(s.end_time) + '</div></div>';
734
+ html += '<div class="detail-section"><div class="detail-label">首条提示词</div><div class="detail-value">' + (s.first_prompt || '—') + '</div></div>';
735
+ html += '<div class="detail-section"><div class="detail-label">任务(' + data.tasks.length + ')</div>';
736
+ data.tasks.forEach((t, i) => {
737
+ const tools = t.events.filter(e => e.tool_name).length;
738
+ html += '<div onclick="openTaskDrawer(' + i + ')" style="padding:0.75rem;background:var(--bg-secondary);border-radius:var(--radius-sm);margin-bottom:0.5rem;border:1px solid var(--border);cursor:pointer;transition:all 0.15s" onmouseover="this.style.borderColor=\'var(--primary)\';this.style.background=\'var(--primary-soft)\'" onmouseout="this.style.borderColor=\'var(--border)\';this.style.background=\'var(--bg-secondary)\'">';
739
+ html += '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px"><div style="font-weight:500;font-size:0.875rem;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:0.5rem">任务 ' + (i+1) + ':' + t.title + '</div><svg width="14" height="14" 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></div>';
740
+ html += '<div style="font-size:0.75rem;color:var(--text-dim)">' + fmtTime(t.start_time) + ' · ' + tools + ' 次工具调用 · ' + t.summary.filesChanged.length + ' 个文件</div>';
741
+ if (t.summary.commits.length > 0) html += '<div style="font-size:0.75rem;color:var(--green);margin-top:4px">✓ ' + t.summary.commits.length + ' 次提交</div>';
742
+ html += '</div>';
726
743
  });
744
+ html += '</div>';
745
+ openDrawer('会话 · ' + s.session_id.slice(0,8), html);
746
+ } catch(e) {
747
+ openDrawer('会话详情', empty('加载失败:' + e.message));
748
+ }
749
+ }
727
750
 
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
+ function openTaskDrawer(taskIndex) {
752
+ if (!currentSessionData || !currentSessionData.tasks[taskIndex]) return;
753
+ const t = currentSessionData.tasks[taskIndex];
754
+ const tools = t.events.filter(e => e.tool_name).length;
755
+
756
+ let html = '<div class="detail-section"><div class="detail-label">标题</div><div class="detail-value" style="font-weight:600">' + t.title + '</div></div>';
757
+ html += '<div class="detail-section"><div class="detail-label">时间</div><div class="detail-value">' + fmt(t.start_time) + (t.end_time ? ' → ' + fmt(t.end_time) : '') + '</div></div>';
758
+ html += '<div class="detail-section"><div class="detail-label">统计</div><div class="detail-value">' + tools + ' 次工具调用 · ' + t.summary.filesChanged.length + ' 个文件变更 · ' + t.summary.commits.length + ' 次提交</div></div>';
759
+
760
+ // User prompts
761
+ if (t.prompts && t.prompts.length > 0) {
762
+ html += '<div class="detail-section"><div class="detail-label">用户提示词(' + t.prompts.length + ')</div>';
763
+ t.prompts.forEach(p => {
764
+ html += '<div style="padding:0.625rem;background:var(--bg-secondary);border-radius:var(--radius-sm);margin-bottom:0.375rem;border:1px solid var(--border);font-size:0.8rem">';
765
+ html += '<div style="color:var(--text-dim);font-size:0.7rem;margin-bottom:2px">' + fmtTime(p.timestamp) + '</div>';
766
+ html += '<div style="white-space:pre-wrap;word-break:break-word">' + (p.content || '—') + '</div>';
767
+ html += '</div>';
751
768
  });
769
+ html += '</div>';
770
+ }
752
771
 
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
- }
772
+ // Tool usage summary
773
+ if (t.summary.toolUsage && Object.keys(t.summary.toolUsage).length > 0) {
774
+ html += '<div class="detail-section"><div class="detail-label">工具使用分布</div><div class="detail-value">';
775
+ Object.entries(t.summary.toolUsage).sort((a,b) => b[1]-a[1]).forEach(([name, count]) => {
776
+ html += '<span class="tag" style="margin-bottom:4px">' + name + ' × ' + count + '</span>';
778
777
  });
779
- } catch (err) {
780
- console.error('Failed to load stats:', err);
778
+ html += '</div></div>';
781
779
  }
782
- }
783
780
 
784
- // ========== Search and Filter Functions ==========
785
- let allEvents = [];
786
- let allRules = [];
787
- let allInjections = [];
781
+ // Files changed
782
+ if (t.summary.filesChanged && t.summary.filesChanged.length > 0) {
783
+ html += '<div class="detail-section"><div class="detail-label">变更的文件(' + t.summary.filesChanged.length + ')</div>';
784
+ t.summary.filesChanged.forEach(f => {
785
+ const short = f.split('/').slice(-2).join('/');
786
+ html += '<div style="font-family:monospace;font-size:0.75rem;color:var(--text-muted);padding:2px 0" title="' + f + '">' + short + '</div>';
787
+ });
788
+ html += '</div>';
789
+ }
788
790
 
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>';
791
+ // Commits
792
+ if (t.summary.commits && t.summary.commits.length > 0) {
793
+ html += '<div class="detail-section"><div class="detail-label">提交记录(' + t.summary.commits.length + ')</div>';
794
+ t.summary.commits.forEach(c => {
795
+ html += '<div style="padding:0.5rem;background:var(--green-soft);border-radius:var(--radius-sm);margin-bottom:0.25rem;font-size:0.8rem;color:var(--text)"><span style="color:var(--green)">✓</span> ' + c.message + '</div>';
796
+ });
797
+ html += '</div>';
796
798
  }
797
- }
798
799
 
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;
800
+ // Injections
801
+ if (t.injections && t.injections.length > 0) {
802
+ html += '<div class="detail-section"><div class="detail-label">注入内容(' + t.injections.length + ')</div>';
803
+ t.injections.forEach(inj => {
804
+ const escaped = inj.content.replace(/</g, '&lt;').replace(/>/g, '&gt;');
805
+ html += '<div style="padding:0.625rem;background:var(--primary-soft);border-radius:var(--radius-sm);margin-bottom:0.375rem;border:1px solid var(--primary-border)">';
806
+ html += '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:4px"><span class="badge badge-info">' + inj.injection_type + '</span><span style="font-size:0.7rem;color:var(--text-dim)">' + inj.source_handler + ' · ' + fmtTime(inj.timestamp) + '</span></div>';
807
+ html += '<div style="font-size:0.75rem;color:var(--text);white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto;font-family:ui-monospace,monospace">' + escaped + '</div>';
808
+ html += '</div>';
809
+ });
810
+ html += '</div>';
803
811
  }
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
812
 
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
- }
813
+ // Quality issues
814
+ if (t.qualityIssues && t.qualityIssues.length > 0) {
815
+ html += '<div class="detail-section"><div class="detail-label">质量问题(' + t.qualityIssues.length + ')</div>';
816
+ t.qualityIssues.forEach(q => {
817
+ const sev = q.severity || 'info';
818
+ const badgeClass = sev === 'must' ? 'badge-block' : sev === 'should' ? 'badge-warn' : 'badge-info';
819
+ const sevLabel = sev === 'must' ? '必须' : sev === 'should' ? '建议' : '提示';
820
+ html += '<div style="padding:0.5rem;background:var(--yellow-soft);border-radius:var(--radius-sm);margin-bottom:0.375rem;border:1px solid var(--border);font-size:0.8rem">';
821
+ html += '<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:2px"><span class="badge ' + badgeClass + '">' + sevLabel + '</span><span style="font-family:monospace;font-size:0.7rem;color:var(--text-dim)">' + (q.file_path || '').split('/').slice(-2).join('/') + '</span></div>';
822
+ html += '<div style="color:var(--text)">' + (q.description || q.message || '') + '</div>';
823
+ html += '</div>';
824
+ });
825
+ html += '</div>';
826
+ }
825
827
 
826
- async function loadRules() {
827
- try {
828
- const res = await fetch(API + '/api/rules');
829
- const rulesData = await res.json();
830
- allRules = [];
828
+ // Governance decisions
829
+ if (t.decisions && t.decisions.length > 0) {
830
+ html += '<div class="detail-section"><div class="detail-label">治理决策(' + t.decisions.length + ')</div>';
831
+ t.decisions.forEach(d => {
832
+ const lv = d.level || 'info';
833
+ const badgeClass = lv === 'block' ? 'badge-block' : lv === 'warn' ? 'badge-warn' : lv === 'allow' ? 'badge-allow' : 'badge-info';
834
+ const lvLabel = lv === 'block' ? '阻断' : lv === 'warn' ? '警告' : lv === 'allow' ? '放行' : lv === 'confirm' ? '确认' : lv;
835
+ html += '<div style="padding:0.5rem;background:var(--bg-secondary);border-radius:var(--radius-sm);margin-bottom:0.375rem;border:1px solid var(--border);font-size:0.8rem">';
836
+ html += '<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:2px"><span class="badge ' + badgeClass + '">' + lvLabel + '</span><span style="font-family:monospace;font-size:0.7rem;color:var(--text-dim)">' + (d.rule_id || '') + '</span></div>';
837
+ html += '<div style="color:var(--text-muted)">' + (d.reason || '—') + '</div>';
838
+ html += '</div>';
839
+ });
840
+ html += '</div>';
841
+ }
831
842
 
832
- Object.entries(rulesData).forEach(([id, conv]) => {
833
- (conv.rules || []).forEach(rule => {
834
- allRules.push({ ...rule, conventionId: id, conventionName: conv.name });
835
- });
843
+ // Recent events
844
+ if (t.events && t.events.length > 0) {
845
+ html += '<div class="detail-section"><div class="detail-label">事件(展示 ' + Math.min(t.events.length, 30) + ' / ' + t.events.length + ')</div>';
846
+ t.events.slice(0, 30).forEach(e => {
847
+ const inputPreview = e.tool_input ? (JSON.stringify(e.tool_input).slice(0, 80) + (JSON.stringify(e.tool_input).length > 80 ? '...' : '')) : '';
848
+ html += '<div style="padding:0.5rem;border-bottom:1px solid var(--border);font-size:0.75rem">';
849
+ html += '<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:2px">';
850
+ html += '<span style="color:var(--text-dim)">' + fmtTime(e.timestamp) + '</span>';
851
+ html += badgeHook(e.hook_type);
852
+ if (e.tool_name) html += '<span style="font-family:monospace;color:var(--text-muted)">' + e.tool_name + '</span>';
853
+ html += '</div>';
854
+ if (inputPreview) html += '<div style="font-family:monospace;color:var(--text-dim);font-size:0.7rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + inputPreview + '</div>';
855
+ html += '</div>';
836
856
  });
857
+ html += '</div>';
858
+ }
837
859
 
838
- renderRules(allRules);
860
+ // Back button
861
+ html = '<div style="margin-bottom:1rem"><button class="btn" onclick="openSessionDrawer(\'' + currentSessionData.session.session_id + '\')">← 返回会话</button></div>' + html;
862
+
863
+ openDrawer('任务:' + t.title.slice(0, 40), html);
864
+ }
865
+
866
+ // === Events ===
867
+ async function loadEvents() {
868
+ document.getElementById('events-body').innerHTML = '<tr><td colspan="4" class="loading">加载中...</td></tr>';
869
+ try {
870
+ const res = await fetch(API + '/api/events?limit=100');
871
+ allEvents = await res.json();
872
+ renderEvents(allEvents);
839
873
  } catch {
840
- document.getElementById('rules-list').innerHTML = '<div class="empty">Failed to load</div>';
874
+ document.getElementById('events-body').innerHTML = '<tr><td colspan="4">' + empty('加载失败') + '</td></tr>';
841
875
  }
842
876
  }
843
877
 
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
- }
878
+ function renderEvents(list) {
879
+ document.getElementById('events-body').innerHTML = list.length === 0
880
+ ? '<tr><td colspan="4">' + empty('暂无事件') + '</td></tr>'
881
+ : list.map(e => '<tr onclick="openEventDrawer(' + JSON.stringify(e).replace(/"/g,'&quot;') + ')"><td style="color:var(--text-dim);font-size:0.8rem">' + fmtTime(e.timestamp) + '</td><td>' + badgeHook(e.hook_type) + '</td><td style="font-family:monospace;font-size:0.8rem">' + (e.tool_name || '—') + '</td><td style="color:var(--text-dim);font-size:0.8rem">' + ((e.project_path||'').split('/').pop() || '—') + '</td></tr>').join('');
882
+ }
849
883
 
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>
859
- </div>
860
- <span style="color:var(--accent);font-size:1.5rem">→</span>
861
- </div>
862
- </div>`;
863
- }).join('');
884
+ function filterEvents() {
885
+ const q = document.getElementById('events-search').value.toLowerCase();
886
+ const t = document.getElementById('events-type').value;
887
+ renderEvents(allEvents.filter(e =>
888
+ (!t || e.hook_type === t) &&
889
+ (!q || (e.hook_type||'').toLowerCase().includes(q) || (e.tool_name||'').toLowerCase().includes(q) || (e.project_path||'').toLowerCase().includes(q))
890
+ ));
864
891
  }
865
892
 
866
- 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);
893
+ function openEventDrawer(e) {
894
+ let html = '<div class="detail-section"><div class="detail-label">类型</div><div class="detail-value">' + badgeHook(e.hook_type) + '</div></div>';
895
+ html += '<div class="detail-section"><div class="detail-label">时间</div><div class="detail-value">' + fmt(e.timestamp) + '</div></div>';
896
+ html += '<div class="detail-section"><div class="detail-label">工具</div><div class="detail-value" style="font-family:monospace">' + (e.tool_name || '') + '</div></div>';
897
+ html += '<div class="detail-section"><div class="detail-label">项目</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">' + (e.project_path || '') + '</div></div>';
898
+ html += '<div class="detail-section"><div class="detail-label">会话</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">' + (e.session_id || '') + '</div></div>';
899
+ if (e.tool_input) html += '<div class="detail-section"><div class="detail-label">输入参数</div><pre class="detail-code">' + JSON.stringify(e.tool_input, null, 2) + '</pre></div>';
900
+ openDrawer(e.hook_type + ' · ' + (e.tool_name || '事件'), html);
875
901
  }
876
902
 
903
+ // === Injections ===
877
904
  async function loadInjections() {
905
+ document.getElementById('inj-list').innerHTML = loading();
878
906
  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);
907
+ const res = await fetch(API + '/api/injections?limit=100');
885
908
  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
909
  renderInjections(allInjections);
900
910
  } catch {
901
- document.getElementById('injections-list').innerHTML = '<div class="empty">Failed to load</div>';
911
+ document.getElementById('inj-list').innerHTML = empty('加载失败');
902
912
  }
903
913
  }
904
914
 
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('');
915
+ function renderInjections(list) {
916
+ document.getElementById('inj-list').innerHTML = list.length === 0 ? empty('暂无注入记录') : list.map(inj => '<div class="list-item fade-in" onclick="openInjDrawer(' + JSON.stringify(inj).replace(/"/g,'&quot;') + ')"><div style="flex:1;min-width:0"><div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:2px"><span class="badge badge-info">' + inj.injection_type + '</span><span style="font-size:0.75rem;color:var(--text-dim)">' + inj.source_handler + '</span></div><div style="font-size:0.8rem;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + inj.content.slice(0,80) + '</div><div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">' + fmt(inj.timestamp) + ' · ' + inj.session_id.slice(0,8) + '</div></div><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></div>').join('');
929
917
  }
930
918
 
931
919
  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);
920
+ const q = document.getElementById('inj-search').value.toLowerCase();
921
+ const h = document.getElementById('inj-handler').value;
922
+ renderInjections(allInjections.filter(inj => (!h || inj.source_handler === h) && (!q || inj.content.toLowerCase().includes(q) || inj.injection_type.toLowerCase().includes(q))));
939
923
  }
940
924
 
941
- // ========== Rule Detail Page ==========
942
- function loadRuleDetail(ruleIdx) {
943
- if (!allRules[ruleIdx]) return;
944
-
945
- showPage('rule-detail');
946
- const rule = allRules[ruleIdx];
947
-
948
- document.getElementById('rule-detail-title').textContent = rule.name || 'Unnamed Rule';
949
-
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';
925
+ function openInjDrawer(inj) {
926
+ let html = '<div class="detail-section"><div class="detail-label">类型</div><div class="detail-value"><span class="badge badge-info">' + inj.injection_type + '</span></div></div>';
927
+ html += '<div class="detail-section"><div class="detail-label">Handler</div><div class="detail-value">' + inj.source_handler + '</div></div>';
928
+ html += '<div class="detail-section"><div class="detail-label">时间</div><div class="detail-value">' + fmt(inj.timestamp) + '</div></div>';
929
+ html += '<div class="detail-section"><div class="detail-label">会话</div><div class="detail-value" style="font-family:monospace;font-size:0.8rem">' + inj.session_id + '</div></div>';
930
+ html += '<div class="detail-section"><div class="detail-label">注入内容</div><pre class="detail-code">' + inj.content + '</pre></div>';
931
+ openDrawer(inj.injection_type, html);
932
+ }
952
933
 
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>
960
- `;
934
+ // === Rules ===
935
+ async function loadRules() {
936
+ document.getElementById('rules-list').innerHTML = loading();
937
+ try {
938
+ const res = await fetch(API + '/api/rules');
939
+ allRules = await res.json();
940
+ renderRules(allRules);
941
+ } catch {
942
+ document.getElementById('rules-list').innerHTML = empty('加载规则失败');
943
+ }
944
+ }
961
945
 
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>
983
- </div>
984
- </div>`;
946
+ function renderRules(list) {
947
+ document.getElementById('rules-list').innerHTML = list.length === 0 ? empty('未找到规则') : list.map(r => '<div class="list-item fade-in" onclick="openRuleDrawer(' + JSON.stringify(r).replace(/"/g,'&quot;') + ')"><div style="flex:1;min-width:0"><div style="font-size:0.875rem;font-weight:500;margin-bottom:2px">' + (r.name || r.id) + '</div><div style="font-size:0.75rem;color:var(--text-dim)">' + (r.description || '') + '</div></div><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></div>').join('');
948
+ }
985
949
 
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>`;
991
- }
950
+ function filterRules() {
951
+ const q = document.getElementById('rules-search').value.toLowerCase();
952
+ renderRules(allRules.filter(r => (r.name||'').toLowerCase().includes(q) || (r.description||'').toLowerCase().includes(q)));
953
+ }
992
954
 
993
- document.getElementById('rule-detail').innerHTML = html;
955
+ function openRuleDrawer(r) {
956
+ let html = '<div class="detail-section"><div class="detail-label">名称</div><div class="detail-value" style="font-weight:600">' + (r.name || '—') + '</div></div>';
957
+ html += '<div class="detail-section"><div class="detail-label">描述</div><div class="detail-value">' + (r.description || '—') + '</div></div>';
958
+ if (r.stats) html += '<div class="detail-section"><div class="detail-label">统计</div><div class="detail-value">触发 ' + (r.stats.totalTriggers || 0) + ' 次 | 阻断 ' + (r.stats.blockCount || 0) + ' 次 | 警告 ' + (r.stats.warnCount || 0) + ' 次</div></div>';
959
+ openDrawer(r.name || '规则', html);
994
960
  }
995
961
 
962
+ // === Init ===
963
+ nav('dashboard');
996
964
  </script>
997
965
  </body>
998
966
  </html>