@farazirfan/costar-server-executor 1.7.67 → 1.7.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/local-database.d.ts +2 -2
- package/dist/db/local-database.d.ts.map +1 -1
- package/dist/db/local-database.js +6 -6
- package/dist/db/local-database.js.map +1 -1
- package/dist/routes/db-ops.d.ts.map +1 -1
- package/dist/routes/db-ops.js +3 -2
- package/dist/routes/db-ops.js.map +1 -1
- package/dist/web-server.d.ts.map +1 -1
- package/dist/web-server.js +52 -10
- package/dist/web-server.js.map +1 -1
- package/package.json +5 -2
- package/public/assets/index-DnNU5Z1l.js +960 -0
- package/public/assets/index-DnNU5Z1l.js.map +1 -0
- package/public/assets/index-p0BMQzJL.css +1 -0
- package/public/index-legacy.html +3261 -0
- package/public/index.html +12 -3864
|
@@ -0,0 +1,3261 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>CoStar Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ─── Reset & Base ─────────────────────────────── */
|
|
9
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--bg-primary: #0f1117;
|
|
13
|
+
--bg-secondary: #161822;
|
|
14
|
+
--bg-card: #1c1e2e;
|
|
15
|
+
--bg-hover: #252840;
|
|
16
|
+
--border: #2a2d42;
|
|
17
|
+
--text-primary: #e4e6f0;
|
|
18
|
+
--text-secondary: #8b8fa8;
|
|
19
|
+
--text-muted: #5c6080;
|
|
20
|
+
--accent: #6366f1;
|
|
21
|
+
--accent-hover: #818cf8;
|
|
22
|
+
--accent-dim: rgba(99,102,241,.15);
|
|
23
|
+
--green: #22c55e;
|
|
24
|
+
--green-dim: rgba(34,197,94,.15);
|
|
25
|
+
--red: #ef4444;
|
|
26
|
+
--red-dim: rgba(239,68,68,.15);
|
|
27
|
+
--yellow: #eab308;
|
|
28
|
+
--yellow-dim: rgba(234,179,8,.15);
|
|
29
|
+
--blue: #3b82f6;
|
|
30
|
+
--blue-dim: rgba(59,130,246,.15);
|
|
31
|
+
--sidebar-w: 240px;
|
|
32
|
+
--header-h: 56px;
|
|
33
|
+
--radius: 8px;
|
|
34
|
+
--radius-lg: 12px;
|
|
35
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
|
36
|
+
--mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[data-theme="light"] {
|
|
40
|
+
--bg-primary: #f8f9fc;
|
|
41
|
+
--bg-secondary: #ffffff;
|
|
42
|
+
--bg-card: #ffffff;
|
|
43
|
+
--bg-hover: #f0f1f5;
|
|
44
|
+
--border: #e2e4eb;
|
|
45
|
+
--text-primary: #1a1c2e;
|
|
46
|
+
--text-secondary: #5c5f77;
|
|
47
|
+
--text-muted: #9496ad;
|
|
48
|
+
--accent: #6366f1;
|
|
49
|
+
--accent-hover: #4f46e5;
|
|
50
|
+
--accent-dim: rgba(99,102,241,.10);
|
|
51
|
+
--green: #16a34a;
|
|
52
|
+
--green-dim: rgba(22,163,74,.10);
|
|
53
|
+
--red: #dc2626;
|
|
54
|
+
--red-dim: rgba(220,38,38,.10);
|
|
55
|
+
--yellow: #ca8a04;
|
|
56
|
+
--yellow-dim: rgba(202,138,4,.10);
|
|
57
|
+
--blue: #2563eb;
|
|
58
|
+
--blue-dim: rgba(37,99,235,.10);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
body {
|
|
62
|
+
font-family: var(--font);
|
|
63
|
+
background: var(--bg-primary);
|
|
64
|
+
color: var(--text-primary);
|
|
65
|
+
height: 100vh;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ─── Layout ───────────────────────────────────── */
|
|
70
|
+
.app {
|
|
71
|
+
display: flex;
|
|
72
|
+
height: 100vh;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.sidebar {
|
|
76
|
+
width: var(--sidebar-w);
|
|
77
|
+
background: var(--bg-secondary);
|
|
78
|
+
border-right: 1px solid var(--border);
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.sidebar-logo {
|
|
85
|
+
padding: 20px 20px 16px;
|
|
86
|
+
border-bottom: 1px solid var(--border);
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 10px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.sidebar-logo .logo-icon {
|
|
93
|
+
width: 32px;
|
|
94
|
+
height: 32px;
|
|
95
|
+
background: linear-gradient(135deg, var(--accent), #a78bfa);
|
|
96
|
+
border-radius: var(--radius);
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
font-size: 16px;
|
|
101
|
+
font-weight: 700;
|
|
102
|
+
color: white;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.sidebar-logo h1 {
|
|
106
|
+
font-size: 18px;
|
|
107
|
+
font-weight: 700;
|
|
108
|
+
letter-spacing: -0.02em;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.sidebar-logo .version {
|
|
112
|
+
font-size: 11px;
|
|
113
|
+
color: var(--text-muted);
|
|
114
|
+
font-weight: 400;
|
|
115
|
+
margin-top: 2px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.sidebar-logo .version span {
|
|
119
|
+
color: var(--accent);
|
|
120
|
+
font-family: var(--mono);
|
|
121
|
+
font-size: 10px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.update-banner {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
justify-content: space-between;
|
|
128
|
+
background: var(--accent-dim);
|
|
129
|
+
border: 1px solid rgba(99,102,241,.3);
|
|
130
|
+
border-radius: var(--radius);
|
|
131
|
+
padding: 10px 14px;
|
|
132
|
+
margin-bottom: 16px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.update-banner .update-info {
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
color: var(--text-primary);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.update-banner .update-info small {
|
|
141
|
+
display: block;
|
|
142
|
+
color: var(--text-secondary);
|
|
143
|
+
font-size: 11px;
|
|
144
|
+
margin-top: 2px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.update-btn {
|
|
148
|
+
background: var(--accent);
|
|
149
|
+
color: white;
|
|
150
|
+
border: none;
|
|
151
|
+
padding: 6px 14px;
|
|
152
|
+
border-radius: var(--radius);
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
transition: background .15s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.update-btn:hover { background: var(--accent-hover); }
|
|
161
|
+
.update-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
162
|
+
|
|
163
|
+
.sidebar-nav {
|
|
164
|
+
flex: 1;
|
|
165
|
+
padding: 12px 10px;
|
|
166
|
+
overflow-y: auto;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.nav-section {
|
|
170
|
+
margin-bottom: 20px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.nav-section-label {
|
|
174
|
+
font-size: 11px;
|
|
175
|
+
text-transform: uppercase;
|
|
176
|
+
letter-spacing: 0.06em;
|
|
177
|
+
color: var(--text-muted);
|
|
178
|
+
padding: 4px 10px 8px;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.nav-item {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 10px;
|
|
186
|
+
padding: 9px 12px;
|
|
187
|
+
border-radius: var(--radius);
|
|
188
|
+
cursor: pointer;
|
|
189
|
+
color: var(--text-secondary);
|
|
190
|
+
font-size: 14px;
|
|
191
|
+
font-weight: 500;
|
|
192
|
+
transition: all .15s;
|
|
193
|
+
border: none;
|
|
194
|
+
background: none;
|
|
195
|
+
width: 100%;
|
|
196
|
+
text-align: left;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.nav-item:hover {
|
|
200
|
+
background: var(--bg-hover);
|
|
201
|
+
color: var(--text-primary);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.nav-item.active {
|
|
205
|
+
background: var(--accent-dim);
|
|
206
|
+
color: var(--accent-hover);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.nav-item .icon {
|
|
210
|
+
font-size: 18px;
|
|
211
|
+
width: 22px;
|
|
212
|
+
text-align: center;
|
|
213
|
+
flex-shrink: 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.nav-item .badge {
|
|
217
|
+
margin-left: auto;
|
|
218
|
+
background: var(--accent-dim);
|
|
219
|
+
color: var(--accent);
|
|
220
|
+
font-size: 11px;
|
|
221
|
+
padding: 2px 7px;
|
|
222
|
+
border-radius: 10px;
|
|
223
|
+
font-weight: 600;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.sidebar-footer {
|
|
227
|
+
padding: 12px 16px;
|
|
228
|
+
border-top: 1px solid var(--border);
|
|
229
|
+
font-size: 12px;
|
|
230
|
+
color: var(--text-muted);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.status-dot {
|
|
234
|
+
display: inline-block;
|
|
235
|
+
width: 8px;
|
|
236
|
+
height: 8px;
|
|
237
|
+
border-radius: 50%;
|
|
238
|
+
background: var(--green);
|
|
239
|
+
margin-right: 6px;
|
|
240
|
+
animation: pulse 2s infinite;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@keyframes pulse {
|
|
244
|
+
0%, 100% { opacity: 1; }
|
|
245
|
+
50% { opacity: .5; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ─── Main Content ─────────────────────────────── */
|
|
249
|
+
.main {
|
|
250
|
+
flex: 1;
|
|
251
|
+
display: flex;
|
|
252
|
+
flex-direction: column;
|
|
253
|
+
overflow: hidden;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.header {
|
|
257
|
+
height: var(--header-h);
|
|
258
|
+
border-bottom: 1px solid var(--border);
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
padding: 0 24px;
|
|
262
|
+
gap: 16px;
|
|
263
|
+
flex-shrink: 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.header h2 {
|
|
267
|
+
font-size: 16px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.header .header-actions {
|
|
272
|
+
margin-left: auto;
|
|
273
|
+
display: flex;
|
|
274
|
+
gap: 8px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.content {
|
|
278
|
+
flex: 1;
|
|
279
|
+
overflow-y: auto;
|
|
280
|
+
padding: 24px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.page { display: none; }
|
|
284
|
+
.page.active { display: block; }
|
|
285
|
+
|
|
286
|
+
/* ─── Cards ────────────────────────────────────── */
|
|
287
|
+
.card {
|
|
288
|
+
background: var(--bg-card);
|
|
289
|
+
border: 1px solid var(--border);
|
|
290
|
+
border-radius: var(--radius-lg);
|
|
291
|
+
padding: 20px;
|
|
292
|
+
margin-bottom: 16px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.card-header {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
justify-content: space-between;
|
|
299
|
+
margin-bottom: 16px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.card-header h3 {
|
|
303
|
+
font-size: 15px;
|
|
304
|
+
font-weight: 600;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.stat-grid {
|
|
308
|
+
display: grid;
|
|
309
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
310
|
+
gap: 16px;
|
|
311
|
+
margin-bottom: 24px;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.stat-card {
|
|
315
|
+
background: var(--bg-card);
|
|
316
|
+
border: 1px solid var(--border);
|
|
317
|
+
border-radius: var(--radius-lg);
|
|
318
|
+
padding: 20px;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.stat-card .stat-label {
|
|
322
|
+
font-size: 12px;
|
|
323
|
+
color: var(--text-secondary);
|
|
324
|
+
text-transform: uppercase;
|
|
325
|
+
letter-spacing: 0.05em;
|
|
326
|
+
margin-bottom: 8px;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.stat-card .stat-value {
|
|
330
|
+
font-size: 28px;
|
|
331
|
+
font-weight: 700;
|
|
332
|
+
letter-spacing: -0.02em;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.stat-card .stat-sub {
|
|
336
|
+
font-size: 12px;
|
|
337
|
+
color: var(--text-muted);
|
|
338
|
+
margin-top: 4px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* ─── Buttons ──────────────────────────────────── */
|
|
342
|
+
.btn {
|
|
343
|
+
padding: 8px 16px;
|
|
344
|
+
border-radius: var(--radius);
|
|
345
|
+
border: 1px solid var(--border);
|
|
346
|
+
background: var(--bg-card);
|
|
347
|
+
color: var(--text-primary);
|
|
348
|
+
font-size: 13px;
|
|
349
|
+
font-weight: 500;
|
|
350
|
+
cursor: pointer;
|
|
351
|
+
transition: all .15s;
|
|
352
|
+
font-family: var(--font);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.btn:hover { background: var(--bg-hover); }
|
|
356
|
+
|
|
357
|
+
.btn-primary {
|
|
358
|
+
background: var(--accent);
|
|
359
|
+
border-color: var(--accent);
|
|
360
|
+
color: white;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.btn-primary:hover { background: var(--accent-hover); }
|
|
364
|
+
|
|
365
|
+
.btn-danger {
|
|
366
|
+
background: var(--red-dim);
|
|
367
|
+
border-color: transparent;
|
|
368
|
+
color: var(--red);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.btn-danger:hover { background: rgba(239,68,68,.25); }
|
|
372
|
+
|
|
373
|
+
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
|
374
|
+
|
|
375
|
+
.btn-icon {
|
|
376
|
+
width: 32px;
|
|
377
|
+
height: 32px;
|
|
378
|
+
padding: 0;
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
justify-content: center;
|
|
382
|
+
font-size: 16px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ─── Tables ───────────────────────────────────── */
|
|
386
|
+
.table-wrap {
|
|
387
|
+
overflow-x: auto;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
table {
|
|
391
|
+
width: 100%;
|
|
392
|
+
border-collapse: collapse;
|
|
393
|
+
font-size: 13px;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
th {
|
|
397
|
+
text-align: left;
|
|
398
|
+
padding: 10px 12px;
|
|
399
|
+
color: var(--text-secondary);
|
|
400
|
+
font-size: 11px;
|
|
401
|
+
text-transform: uppercase;
|
|
402
|
+
letter-spacing: 0.06em;
|
|
403
|
+
font-weight: 600;
|
|
404
|
+
border-bottom: 1px solid var(--border);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
td {
|
|
408
|
+
padding: 12px;
|
|
409
|
+
border-bottom: 1px solid var(--border);
|
|
410
|
+
vertical-align: middle;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
tr:last-child td { border-bottom: none; }
|
|
414
|
+
tr:hover td { background: var(--bg-hover); }
|
|
415
|
+
|
|
416
|
+
/* ─── Tags / Badges ────────────────────────────── */
|
|
417
|
+
.tag {
|
|
418
|
+
display: inline-flex;
|
|
419
|
+
align-items: center;
|
|
420
|
+
gap: 4px;
|
|
421
|
+
padding: 3px 8px;
|
|
422
|
+
border-radius: 6px;
|
|
423
|
+
font-size: 11px;
|
|
424
|
+
font-weight: 600;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.tag-green { background: var(--green-dim); color: var(--green); }
|
|
428
|
+
.tag-red { background: var(--red-dim); color: var(--red); }
|
|
429
|
+
.tag-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
|
430
|
+
.tag-blue { background: var(--blue-dim); color: var(--blue); }
|
|
431
|
+
.tag-muted { background: rgba(140,144,170,.1); color: var(--text-secondary); }
|
|
432
|
+
|
|
433
|
+
/* ─── Forms ────────────────────────────────────── */
|
|
434
|
+
input[type="text"], input[type="search"], textarea, select {
|
|
435
|
+
width: 100%;
|
|
436
|
+
padding: 9px 12px;
|
|
437
|
+
background: var(--bg-primary);
|
|
438
|
+
border: 1px solid var(--border);
|
|
439
|
+
border-radius: var(--radius);
|
|
440
|
+
color: var(--text-primary);
|
|
441
|
+
font-size: 13px;
|
|
442
|
+
font-family: var(--font);
|
|
443
|
+
outline: none;
|
|
444
|
+
transition: border-color .15s;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
input:focus, textarea:focus, select:focus {
|
|
448
|
+
border-color: var(--accent);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
textarea {
|
|
452
|
+
resize: vertical;
|
|
453
|
+
min-height: 100px;
|
|
454
|
+
font-family: var(--mono);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
label {
|
|
458
|
+
display: block;
|
|
459
|
+
font-size: 12px;
|
|
460
|
+
font-weight: 600;
|
|
461
|
+
color: var(--text-secondary);
|
|
462
|
+
margin-bottom: 6px;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.form-group {
|
|
466
|
+
margin-bottom: 16px;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* ─── Chat ─────────────────────────────────────── */
|
|
470
|
+
.chat-container {
|
|
471
|
+
display: flex;
|
|
472
|
+
flex-direction: column;
|
|
473
|
+
height: calc(100vh - var(--header-h) - 48px);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.chat-messages {
|
|
477
|
+
flex: 1;
|
|
478
|
+
overflow-y: auto;
|
|
479
|
+
padding: 16px 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.chat-msg {
|
|
483
|
+
display: flex;
|
|
484
|
+
gap: 12px;
|
|
485
|
+
padding: 12px 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.chat-msg .avatar {
|
|
489
|
+
width: 32px;
|
|
490
|
+
height: 32px;
|
|
491
|
+
border-radius: 50%;
|
|
492
|
+
display: flex;
|
|
493
|
+
align-items: center;
|
|
494
|
+
justify-content: center;
|
|
495
|
+
font-size: 14px;
|
|
496
|
+
flex-shrink: 0;
|
|
497
|
+
font-weight: 600;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.chat-msg.user .avatar {
|
|
501
|
+
background: var(--accent-dim);
|
|
502
|
+
color: var(--accent);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.chat-msg.assistant .avatar {
|
|
506
|
+
background: var(--green-dim);
|
|
507
|
+
color: var(--green);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.chat-msg .msg-content {
|
|
511
|
+
flex: 1;
|
|
512
|
+
line-height: 1.6;
|
|
513
|
+
font-size: 14px;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.chat-msg .msg-content .msg-role {
|
|
517
|
+
font-size: 12px;
|
|
518
|
+
font-weight: 600;
|
|
519
|
+
color: var(--text-secondary);
|
|
520
|
+
margin-bottom: 4px;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.chat-input-wrap {
|
|
524
|
+
display: flex;
|
|
525
|
+
gap: 8px;
|
|
526
|
+
padding-top: 16px;
|
|
527
|
+
border-top: 1px solid var(--border);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.chat-input-wrap input {
|
|
531
|
+
flex: 1;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* ─── VNC Live Browser Panel ──────────────────── */
|
|
535
|
+
.vnc-panel {
|
|
536
|
+
display: none;
|
|
537
|
+
border-top: 1px solid var(--border);
|
|
538
|
+
background: var(--bg-secondary);
|
|
539
|
+
flex-shrink: 0;
|
|
540
|
+
overflow: hidden;
|
|
541
|
+
}
|
|
542
|
+
.vnc-panel.active { display: block; }
|
|
543
|
+
|
|
544
|
+
.vnc-panel-header {
|
|
545
|
+
display: flex;
|
|
546
|
+
align-items: center;
|
|
547
|
+
gap: 8px;
|
|
548
|
+
padding: 8px 12px;
|
|
549
|
+
cursor: pointer;
|
|
550
|
+
user-select: none;
|
|
551
|
+
transition: background 0.15s;
|
|
552
|
+
}
|
|
553
|
+
.vnc-panel-header:hover { background: var(--bg-hover); }
|
|
554
|
+
|
|
555
|
+
.vnc-panel-title {
|
|
556
|
+
font-size: 12px;
|
|
557
|
+
font-weight: 600;
|
|
558
|
+
color: var(--green);
|
|
559
|
+
font-family: var(--mono);
|
|
560
|
+
}
|
|
561
|
+
.vnc-panel-dot {
|
|
562
|
+
width: 8px;
|
|
563
|
+
height: 8px;
|
|
564
|
+
border-radius: 50%;
|
|
565
|
+
background: var(--green);
|
|
566
|
+
animation: vnc-pulse 2s infinite;
|
|
567
|
+
}
|
|
568
|
+
@keyframes vnc-pulse {
|
|
569
|
+
0%, 100% { opacity: 1; }
|
|
570
|
+
50% { opacity: 0.4; }
|
|
571
|
+
}
|
|
572
|
+
.vnc-panel-toggle {
|
|
573
|
+
font-size: 10px;
|
|
574
|
+
color: var(--text-muted);
|
|
575
|
+
transition: transform 0.2s;
|
|
576
|
+
}
|
|
577
|
+
.vnc-panel.collapsed .vnc-panel-toggle { transform: rotate(180deg); }
|
|
578
|
+
|
|
579
|
+
.vnc-panel-body {
|
|
580
|
+
height: 400px;
|
|
581
|
+
transition: height 0.3s ease;
|
|
582
|
+
}
|
|
583
|
+
.vnc-panel.collapsed .vnc-panel-body {
|
|
584
|
+
height: 0;
|
|
585
|
+
overflow: hidden;
|
|
586
|
+
}
|
|
587
|
+
.vnc-panel-body iframe {
|
|
588
|
+
width: 100%;
|
|
589
|
+
height: 100%;
|
|
590
|
+
border: none;
|
|
591
|
+
background: #000;
|
|
592
|
+
}
|
|
593
|
+
.vnc-panel-actions {
|
|
594
|
+
margin-left: auto;
|
|
595
|
+
display: flex;
|
|
596
|
+
gap: 6px;
|
|
597
|
+
align-items: center;
|
|
598
|
+
}
|
|
599
|
+
.vnc-open-btn {
|
|
600
|
+
font-size: 11px;
|
|
601
|
+
color: var(--text-muted);
|
|
602
|
+
background: none;
|
|
603
|
+
border: 1px solid var(--border);
|
|
604
|
+
border-radius: 4px;
|
|
605
|
+
padding: 2px 8px;
|
|
606
|
+
cursor: pointer;
|
|
607
|
+
font-family: var(--mono);
|
|
608
|
+
}
|
|
609
|
+
.vnc-open-btn:hover {
|
|
610
|
+
color: var(--text-primary);
|
|
611
|
+
border-color: var(--text-secondary);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/* ─── Chat Tool Cards ──────────────────────────── */
|
|
615
|
+
.tool-card {
|
|
616
|
+
background: var(--bg-secondary);
|
|
617
|
+
border: 1px solid var(--border);
|
|
618
|
+
border-radius: var(--radius);
|
|
619
|
+
margin: 8px 0;
|
|
620
|
+
font-size: 13px;
|
|
621
|
+
overflow: hidden;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.tool-card-header {
|
|
625
|
+
display: flex;
|
|
626
|
+
align-items: center;
|
|
627
|
+
gap: 8px;
|
|
628
|
+
padding: 8px 12px;
|
|
629
|
+
cursor: pointer;
|
|
630
|
+
user-select: none;
|
|
631
|
+
transition: background 0.15s;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.tool-card-header:hover {
|
|
635
|
+
background: var(--bg-hover);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.tool-icon {
|
|
639
|
+
font-size: 14px;
|
|
640
|
+
flex-shrink: 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.tool-icon.running {
|
|
644
|
+
animation: spin 1s linear infinite;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
@keyframes spin {
|
|
648
|
+
from { transform: rotate(0deg); }
|
|
649
|
+
to { transform: rotate(360deg); }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.tool-name {
|
|
653
|
+
font-weight: 600;
|
|
654
|
+
color: var(--accent);
|
|
655
|
+
font-family: var(--mono);
|
|
656
|
+
font-size: 12px;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.tool-status {
|
|
660
|
+
margin-left: auto;
|
|
661
|
+
font-size: 11px;
|
|
662
|
+
font-family: var(--mono);
|
|
663
|
+
color: var(--text-muted);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.tool-status.success {
|
|
667
|
+
color: var(--green);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.tool-status.error {
|
|
671
|
+
color: var(--red);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
.tool-chevron {
|
|
675
|
+
font-size: 10px;
|
|
676
|
+
color: var(--text-muted);
|
|
677
|
+
transition: transform 0.2s;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.tool-card.expanded .tool-chevron {
|
|
681
|
+
transform: rotate(90deg);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.tool-card-body {
|
|
685
|
+
display: none;
|
|
686
|
+
border-top: 1px solid var(--border);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.tool-card.expanded .tool-card-body {
|
|
690
|
+
display: block;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.tool-section {
|
|
694
|
+
padding: 8px 12px;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.tool-section + .tool-section {
|
|
698
|
+
border-top: 1px solid var(--border);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.tool-section-label {
|
|
702
|
+
font-size: 10px;
|
|
703
|
+
font-weight: 600;
|
|
704
|
+
text-transform: uppercase;
|
|
705
|
+
letter-spacing: 0.05em;
|
|
706
|
+
color: var(--text-muted);
|
|
707
|
+
margin-bottom: 4px;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.tool-section pre {
|
|
711
|
+
font-family: var(--mono);
|
|
712
|
+
font-size: 11px;
|
|
713
|
+
color: var(--text-secondary);
|
|
714
|
+
white-space: pre-wrap;
|
|
715
|
+
word-break: break-all;
|
|
716
|
+
max-height: 200px;
|
|
717
|
+
overflow-y: auto;
|
|
718
|
+
margin: 0;
|
|
719
|
+
line-height: 1.5;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/* Message flow — interleaved text segments and tool cards */
|
|
723
|
+
.msg-flow {
|
|
724
|
+
display: flex;
|
|
725
|
+
flex-direction: column;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.msg-text-segment:empty:not(.streaming-cursor) {
|
|
729
|
+
display: none;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/* Compaction banner — fixed above chat input */
|
|
733
|
+
.compaction-banner {
|
|
734
|
+
display: none;
|
|
735
|
+
align-items: center;
|
|
736
|
+
gap: 8px;
|
|
737
|
+
padding: 8px 12px;
|
|
738
|
+
margin: 0;
|
|
739
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
740
|
+
background: rgba(99, 102, 241, 0.08);
|
|
741
|
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
|
742
|
+
border-bottom: none;
|
|
743
|
+
font-size: 12px;
|
|
744
|
+
color: var(--accent);
|
|
745
|
+
font-family: var(--mono);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.compaction-banner.active { display: flex; }
|
|
749
|
+
|
|
750
|
+
.compaction-banner .compaction-icon {
|
|
751
|
+
animation: spin 1s linear infinite;
|
|
752
|
+
font-size: 14px;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.compaction-banner.compaction-done {
|
|
756
|
+
background: rgba(34, 197, 94, 0.08);
|
|
757
|
+
border-color: rgba(34, 197, 94, 0.2);
|
|
758
|
+
border-bottom: none;
|
|
759
|
+
color: var(--green);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.compaction-banner.compaction-done .compaction-icon {
|
|
763
|
+
animation: none;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.compaction-banner.compaction-error {
|
|
767
|
+
background: rgba(239, 68, 68, 0.08);
|
|
768
|
+
border-color: rgba(239, 68, 68, 0.2);
|
|
769
|
+
border-bottom: none;
|
|
770
|
+
color: var(--red);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.compaction-banner.compaction-error .compaction-icon {
|
|
774
|
+
animation: none;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/* Streaming cursor */
|
|
778
|
+
.streaming-cursor::after {
|
|
779
|
+
content: "";
|
|
780
|
+
display: inline-block;
|
|
781
|
+
width: 8px;
|
|
782
|
+
height: 16px;
|
|
783
|
+
background: var(--accent);
|
|
784
|
+
margin-left: 2px;
|
|
785
|
+
animation: blink 0.8s step-end infinite;
|
|
786
|
+
vertical-align: text-bottom;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
@keyframes blink {
|
|
790
|
+
50% { opacity: 0; }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/* Chat metadata bar */
|
|
794
|
+
.chat-meta {
|
|
795
|
+
display: flex;
|
|
796
|
+
gap: 12px;
|
|
797
|
+
padding: 6px 0;
|
|
798
|
+
font-size: 11px;
|
|
799
|
+
color: var(--text-muted);
|
|
800
|
+
font-family: var(--mono);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.chat-meta span {
|
|
804
|
+
display: flex;
|
|
805
|
+
align-items: center;
|
|
806
|
+
gap: 4px;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/* ─── Logs ─────────────────────────────────────── */
|
|
810
|
+
.log-toolbar {
|
|
811
|
+
display: flex;
|
|
812
|
+
gap: 8px;
|
|
813
|
+
align-items: center;
|
|
814
|
+
margin-bottom: 16px;
|
|
815
|
+
flex-wrap: wrap;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.log-toolbar select {
|
|
819
|
+
width: auto;
|
|
820
|
+
min-width: 130px;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.log-entries {
|
|
824
|
+
font-family: var(--mono);
|
|
825
|
+
font-size: 12px;
|
|
826
|
+
line-height: 1.8;
|
|
827
|
+
max-height: calc(100vh - 250px);
|
|
828
|
+
overflow-y: auto;
|
|
829
|
+
background: var(--bg-primary);
|
|
830
|
+
border: 1px solid var(--border);
|
|
831
|
+
border-radius: var(--radius);
|
|
832
|
+
padding: 12px;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.log-line {
|
|
836
|
+
display: flex;
|
|
837
|
+
gap: 8px;
|
|
838
|
+
padding: 2px 0;
|
|
839
|
+
white-space: nowrap;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.log-line:hover { background: var(--bg-hover); }
|
|
843
|
+
|
|
844
|
+
.log-line .log-time {
|
|
845
|
+
color: var(--text-muted);
|
|
846
|
+
flex-shrink: 0;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.log-line .log-level {
|
|
850
|
+
width: 50px;
|
|
851
|
+
flex-shrink: 0;
|
|
852
|
+
font-weight: 600;
|
|
853
|
+
text-transform: uppercase;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.log-line .log-level.info { color: var(--blue); }
|
|
857
|
+
.log-line .log-level.warn { color: var(--yellow); }
|
|
858
|
+
.log-line .log-level.error { color: var(--red); }
|
|
859
|
+
.log-line .log-level.debug { color: var(--text-muted); }
|
|
860
|
+
|
|
861
|
+
.log-line .log-comp {
|
|
862
|
+
color: var(--accent);
|
|
863
|
+
flex-shrink: 0;
|
|
864
|
+
min-width: 80px;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.log-line .log-msg {
|
|
868
|
+
color: var(--text-primary);
|
|
869
|
+
overflow: hidden;
|
|
870
|
+
text-overflow: ellipsis;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/* ─── Workspace Editor ──────────────────────────── */
|
|
874
|
+
.workspace-split {
|
|
875
|
+
display: grid;
|
|
876
|
+
grid-template-columns: 260px 1fr;
|
|
877
|
+
gap: 16px;
|
|
878
|
+
height: calc(100vh - var(--header-h) - 48px);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.workspace-list {
|
|
882
|
+
overflow-y: auto;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.file-item {
|
|
886
|
+
display: flex;
|
|
887
|
+
align-items: center;
|
|
888
|
+
gap: 8px;
|
|
889
|
+
padding: 10px 12px;
|
|
890
|
+
border-radius: var(--radius);
|
|
891
|
+
cursor: pointer;
|
|
892
|
+
color: var(--text-secondary);
|
|
893
|
+
font-size: 13px;
|
|
894
|
+
transition: all .15s;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
.file-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
898
|
+
.file-item.active { background: var(--accent-dim); color: var(--accent-hover); }
|
|
899
|
+
|
|
900
|
+
.file-item .file-icon { font-size: 16px; }
|
|
901
|
+
|
|
902
|
+
.file-item .file-meta {
|
|
903
|
+
margin-left: auto;
|
|
904
|
+
font-size: 11px;
|
|
905
|
+
color: var(--text-muted);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.workspace-editor {
|
|
909
|
+
display: flex;
|
|
910
|
+
flex-direction: column;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.workspace-editor textarea {
|
|
914
|
+
flex: 1;
|
|
915
|
+
min-height: unset;
|
|
916
|
+
resize: none;
|
|
917
|
+
line-height: 1.7;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.editor-toolbar {
|
|
921
|
+
display: flex;
|
|
922
|
+
justify-content: space-between;
|
|
923
|
+
align-items: center;
|
|
924
|
+
padding: 8px 0;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* ─── Settings ──────────────────────────────────── */
|
|
928
|
+
.config-list {
|
|
929
|
+
font-family: var(--mono);
|
|
930
|
+
font-size: 13px;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
.config-row {
|
|
934
|
+
display: flex;
|
|
935
|
+
padding: 10px 0;
|
|
936
|
+
border-bottom: 1px solid var(--border);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.config-row:last-child { border-bottom: none; }
|
|
940
|
+
|
|
941
|
+
.config-key {
|
|
942
|
+
width: 280px;
|
|
943
|
+
color: var(--accent);
|
|
944
|
+
flex-shrink: 0;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.config-value {
|
|
948
|
+
color: var(--text-primary);
|
|
949
|
+
word-break: break-all;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/* ─── Empty states ──────────────────────────────── */
|
|
953
|
+
.empty-state {
|
|
954
|
+
text-align: center;
|
|
955
|
+
padding: 60px 20px;
|
|
956
|
+
color: var(--text-muted);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.empty-state .empty-icon {
|
|
960
|
+
font-size: 48px;
|
|
961
|
+
margin-bottom: 16px;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.empty-state p {
|
|
965
|
+
font-size: 14px;
|
|
966
|
+
max-width: 400px;
|
|
967
|
+
margin: 0 auto;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/* ─── Modal ────────────────────────────────────── */
|
|
971
|
+
.modal-overlay {
|
|
972
|
+
display: none;
|
|
973
|
+
position: fixed;
|
|
974
|
+
inset: 0;
|
|
975
|
+
background: rgba(0,0,0,.6);
|
|
976
|
+
z-index: 100;
|
|
977
|
+
align-items: center;
|
|
978
|
+
justify-content: center;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.modal-overlay.open { display: flex; }
|
|
982
|
+
|
|
983
|
+
.modal {
|
|
984
|
+
background: var(--bg-card);
|
|
985
|
+
border: 1px solid var(--border);
|
|
986
|
+
border-radius: var(--radius-lg);
|
|
987
|
+
width: 100%;
|
|
988
|
+
max-width: 520px;
|
|
989
|
+
padding: 24px;
|
|
990
|
+
max-height: 80vh;
|
|
991
|
+
overflow-y: auto;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.modal h3 {
|
|
995
|
+
font-size: 16px;
|
|
996
|
+
font-weight: 600;
|
|
997
|
+
margin-bottom: 20px;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.modal-actions {
|
|
1001
|
+
display: flex;
|
|
1002
|
+
justify-content: flex-end;
|
|
1003
|
+
gap: 8px;
|
|
1004
|
+
margin-top: 20px;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/* ─── File Tabs ────────────────────────────────── */
|
|
1008
|
+
.file-tabs {
|
|
1009
|
+
display: flex;
|
|
1010
|
+
gap: 4px;
|
|
1011
|
+
border-bottom: 1px solid var(--border);
|
|
1012
|
+
margin-bottom: 12px;
|
|
1013
|
+
overflow-x: auto;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.file-tab {
|
|
1017
|
+
padding: 8px 14px;
|
|
1018
|
+
font-size: 12px;
|
|
1019
|
+
font-family: var(--mono);
|
|
1020
|
+
color: var(--text-secondary);
|
|
1021
|
+
background: transparent;
|
|
1022
|
+
border: none;
|
|
1023
|
+
border-bottom: 2px solid transparent;
|
|
1024
|
+
cursor: pointer;
|
|
1025
|
+
transition: all .15s;
|
|
1026
|
+
white-space: nowrap;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.file-tab:hover {
|
|
1030
|
+
color: var(--text-primary);
|
|
1031
|
+
background: var(--bg-hover);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.file-tab.active {
|
|
1035
|
+
color: var(--accent);
|
|
1036
|
+
border-bottom-color: var(--accent);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.file-content {
|
|
1040
|
+
display: none;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.file-content.active {
|
|
1044
|
+
display: block;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/* ─── Scrollbar ─────────────────────────────────── */
|
|
1048
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
1049
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1050
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
1051
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
|
1052
|
+
|
|
1053
|
+
/* ─── Loading ───────────────────────────────────── */
|
|
1054
|
+
.spinner {
|
|
1055
|
+
width: 20px;
|
|
1056
|
+
height: 20px;
|
|
1057
|
+
border: 2px solid var(--border);
|
|
1058
|
+
border-top-color: var(--accent);
|
|
1059
|
+
border-radius: 50%;
|
|
1060
|
+
animation: spin .6s linear infinite;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1064
|
+
|
|
1065
|
+
.loading-center {
|
|
1066
|
+
display: flex;
|
|
1067
|
+
align-items: center;
|
|
1068
|
+
justify-content: center;
|
|
1069
|
+
padding: 60px;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/* ─── Skill card grid ───────────────────────────── */
|
|
1073
|
+
.skill-grid {
|
|
1074
|
+
display: grid;
|
|
1075
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
1076
|
+
gap: 16px;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.skill-card {
|
|
1080
|
+
background: var(--bg-card);
|
|
1081
|
+
border: 1px solid var(--border);
|
|
1082
|
+
border-radius: var(--radius-lg);
|
|
1083
|
+
padding: 20px;
|
|
1084
|
+
cursor: pointer;
|
|
1085
|
+
transition: all .15s;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
.skill-card:hover { border-color: var(--accent); transform: translateY(-1px); }
|
|
1089
|
+
|
|
1090
|
+
.skill-card .skill-header {
|
|
1091
|
+
display: flex;
|
|
1092
|
+
align-items: center;
|
|
1093
|
+
gap: 10px;
|
|
1094
|
+
margin-bottom: 10px;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.skill-card .skill-emoji { font-size: 24px; }
|
|
1098
|
+
|
|
1099
|
+
.skill-card .skill-name {
|
|
1100
|
+
font-size: 14px;
|
|
1101
|
+
font-weight: 600;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.skill-card .skill-desc {
|
|
1105
|
+
font-size: 13px;
|
|
1106
|
+
color: var(--text-secondary);
|
|
1107
|
+
line-height: 1.5;
|
|
1108
|
+
display: -webkit-box;
|
|
1109
|
+
-webkit-line-clamp: 3;
|
|
1110
|
+
-webkit-box-orient: vertical;
|
|
1111
|
+
overflow: hidden;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.skill-card .skill-footer {
|
|
1115
|
+
margin-top: 12px;
|
|
1116
|
+
display: flex;
|
|
1117
|
+
gap: 6px;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/* ─── Theme Toggle ──────────────────────────────── */
|
|
1121
|
+
.theme-toggle {
|
|
1122
|
+
background: var(--bg-card);
|
|
1123
|
+
border: 1px solid var(--border);
|
|
1124
|
+
border-radius: var(--radius);
|
|
1125
|
+
color: var(--text-secondary);
|
|
1126
|
+
cursor: pointer;
|
|
1127
|
+
padding: 6px 10px;
|
|
1128
|
+
font-size: 16px;
|
|
1129
|
+
line-height: 1;
|
|
1130
|
+
transition: background .15s, color .15s;
|
|
1131
|
+
}
|
|
1132
|
+
.theme-toggle:hover {
|
|
1133
|
+
background: var(--bg-hover);
|
|
1134
|
+
color: var(--text-primary);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/* ─── Light Mode Fixes ───────────────────────────── */
|
|
1138
|
+
[data-theme="light"] .sidebar {
|
|
1139
|
+
border-right: 1px solid var(--border);
|
|
1140
|
+
}
|
|
1141
|
+
[data-theme="light"] .card,
|
|
1142
|
+
[data-theme="light"] .stat-card,
|
|
1143
|
+
[data-theme="light"] .skill-card {
|
|
1144
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
1145
|
+
}
|
|
1146
|
+
[data-theme="light"] .modal-content {
|
|
1147
|
+
box-shadow: 0 8px 30px rgba(0,0,0,.12);
|
|
1148
|
+
}
|
|
1149
|
+
[data-theme="light"] .logo-icon {
|
|
1150
|
+
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
|
1151
|
+
}
|
|
1152
|
+
[data-theme="light"] code,
|
|
1153
|
+
[data-theme="light"] .log-line {
|
|
1154
|
+
background: var(--bg-primary);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/* ─── Responsive ────────────────────────────────── */
|
|
1158
|
+
@media (max-width: 768px) {
|
|
1159
|
+
.sidebar { display: none; }
|
|
1160
|
+
.stat-grid { grid-template-columns: 1fr 1fr; }
|
|
1161
|
+
.workspace-split { grid-template-columns: 1fr; }
|
|
1162
|
+
}
|
|
1163
|
+
</style>
|
|
1164
|
+
<script>
|
|
1165
|
+
// Apply saved theme immediately to prevent flash of wrong theme
|
|
1166
|
+
(function(){var t=localStorage.getItem('costar-theme');if(t)document.documentElement.setAttribute('data-theme',t);})();
|
|
1167
|
+
</script>
|
|
1168
|
+
</head>
|
|
1169
|
+
<body>
|
|
1170
|
+
<div class="app">
|
|
1171
|
+
<!-- Sidebar -->
|
|
1172
|
+
<aside class="sidebar">
|
|
1173
|
+
<div class="sidebar-logo">
|
|
1174
|
+
<div class="logo-icon">C</div>
|
|
1175
|
+
<div>
|
|
1176
|
+
<h1>CoStar</h1>
|
|
1177
|
+
<div class="version">Server Executor <span id="sidebar-version"></span></div>
|
|
1178
|
+
</div>
|
|
1179
|
+
</div>
|
|
1180
|
+
|
|
1181
|
+
<nav class="sidebar-nav">
|
|
1182
|
+
<div class="nav-section">
|
|
1183
|
+
<div class="nav-section-label">Overview</div>
|
|
1184
|
+
<button class="nav-item active" data-page="dashboard" onclick="navigate('dashboard')">
|
|
1185
|
+
<span class="icon">⚙</span> Dashboard
|
|
1186
|
+
</button>
|
|
1187
|
+
</div>
|
|
1188
|
+
|
|
1189
|
+
<div class="nav-section">
|
|
1190
|
+
<div class="nav-section-label">Agent</div>
|
|
1191
|
+
<button class="nav-item" data-page="chat" onclick="navigate('chat')">
|
|
1192
|
+
<span class="icon">💬</span> Chat
|
|
1193
|
+
</button>
|
|
1194
|
+
<button class="nav-item" data-page="sessions" onclick="navigate('sessions')">
|
|
1195
|
+
<span class="icon">📋</span> Sessions
|
|
1196
|
+
<span class="badge" id="sessions-badge" style="display:none">0</span>
|
|
1197
|
+
</button>
|
|
1198
|
+
</div>
|
|
1199
|
+
|
|
1200
|
+
<div class="nav-section">
|
|
1201
|
+
<div class="nav-section-label">Automation</div>
|
|
1202
|
+
<button class="nav-item" data-page="cron" onclick="navigate('cron')">
|
|
1203
|
+
<span class="icon">⏰</span> Cron Jobs
|
|
1204
|
+
<span class="badge" id="cron-badge">0</span>
|
|
1205
|
+
</button>
|
|
1206
|
+
</div>
|
|
1207
|
+
|
|
1208
|
+
<div class="nav-section">
|
|
1209
|
+
<div class="nav-section-label">Configuration</div>
|
|
1210
|
+
<button class="nav-item" data-page="skills" onclick="navigate('skills')">
|
|
1211
|
+
<span class="icon">⚡</span> Skills
|
|
1212
|
+
<span class="badge" id="skills-badge">0</span>
|
|
1213
|
+
</button>
|
|
1214
|
+
<button class="nav-item" data-page="workspace" onclick="navigate('workspace')">
|
|
1215
|
+
<span class="icon">📄</span> Workspace
|
|
1216
|
+
</button>
|
|
1217
|
+
</div>
|
|
1218
|
+
|
|
1219
|
+
<div class="nav-section">
|
|
1220
|
+
<div class="nav-section-label">System</div>
|
|
1221
|
+
<button class="nav-item" data-page="logs" onclick="navigate('logs')">
|
|
1222
|
+
<span class="icon">📋</span> Logs
|
|
1223
|
+
</button>
|
|
1224
|
+
<button class="nav-item" data-page="settings" onclick="navigate('settings')">
|
|
1225
|
+
<span class="icon">🔧</span> Settings
|
|
1226
|
+
</button>
|
|
1227
|
+
</div>
|
|
1228
|
+
</nav>
|
|
1229
|
+
|
|
1230
|
+
<div class="sidebar-footer">
|
|
1231
|
+
<span class="status-dot"></span> Agent running
|
|
1232
|
+
</div>
|
|
1233
|
+
</aside>
|
|
1234
|
+
|
|
1235
|
+
<!-- Main -->
|
|
1236
|
+
<main class="main">
|
|
1237
|
+
<div class="header">
|
|
1238
|
+
<h2 id="page-title">Dashboard</h2>
|
|
1239
|
+
<div class="header-actions" id="header-actions">
|
|
1240
|
+
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">☾</button>
|
|
1241
|
+
</div>
|
|
1242
|
+
</div>
|
|
1243
|
+
|
|
1244
|
+
<div class="content">
|
|
1245
|
+
|
|
1246
|
+
<!-- ═══ Dashboard Page ═══ -->
|
|
1247
|
+
<div class="page active" id="page-dashboard">
|
|
1248
|
+
<div id="update-banner-container"></div>
|
|
1249
|
+
<div class="stat-grid" id="stats-grid">
|
|
1250
|
+
<div class="stat-card">
|
|
1251
|
+
<div class="stat-label">Uptime</div>
|
|
1252
|
+
<div class="stat-value" id="stat-uptime">--</div>
|
|
1253
|
+
<div class="stat-sub" id="stat-platform">--</div>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div class="stat-card">
|
|
1256
|
+
<div class="stat-label">Cron Jobs</div>
|
|
1257
|
+
<div class="stat-value" id="stat-cron">--</div>
|
|
1258
|
+
<div class="stat-sub" id="stat-cron-sub">--</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
<div class="stat-card">
|
|
1261
|
+
<div class="stat-label">Skills</div>
|
|
1262
|
+
<div class="stat-value" id="stat-skills">--</div>
|
|
1263
|
+
<div class="stat-sub" id="stat-skills-sub">--</div>
|
|
1264
|
+
</div>
|
|
1265
|
+
<div class="stat-card">
|
|
1266
|
+
<div class="stat-label">Memory</div>
|
|
1267
|
+
<div class="stat-value" id="stat-memory">--</div>
|
|
1268
|
+
<div class="stat-sub" id="stat-memory-sub">--</div>
|
|
1269
|
+
</div>
|
|
1270
|
+
</div>
|
|
1271
|
+
|
|
1272
|
+
<div class="card">
|
|
1273
|
+
<div class="card-header">
|
|
1274
|
+
<h3>System Info</h3>
|
|
1275
|
+
<button class="btn btn-sm" onclick="loadDashboard()">Refresh</button>
|
|
1276
|
+
</div>
|
|
1277
|
+
<div class="config-list" id="system-info">
|
|
1278
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
|
|
1282
|
+
<!-- Session Management Card -->
|
|
1283
|
+
<div class="card">
|
|
1284
|
+
<div class="card-header">
|
|
1285
|
+
<h3>Agent Session</h3>
|
|
1286
|
+
<button class="btn btn-sm" onclick="loadSessionInfo()">Refresh</button>
|
|
1287
|
+
</div>
|
|
1288
|
+
<div class="config-list" id="session-info">
|
|
1289
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1290
|
+
</div>
|
|
1291
|
+
<div style="padding: 0 16px 16px; display: flex; gap: 10px; flex-wrap: wrap;">
|
|
1292
|
+
<button class="btn btn-sm btn-primary" id="compact-btn" onclick="triggerCompaction()">
|
|
1293
|
+
📦 Compact Session
|
|
1294
|
+
</button>
|
|
1295
|
+
<button class="btn btn-sm btn-danger" id="reset-session-btn" onclick="triggerResetSession()">
|
|
1296
|
+
🗑 Reset Session
|
|
1297
|
+
</button>
|
|
1298
|
+
</div>
|
|
1299
|
+
</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
|
|
1302
|
+
<!-- ═══ Chat Page ═══ -->
|
|
1303
|
+
<div class="page" id="page-chat">
|
|
1304
|
+
<div class="chat-container">
|
|
1305
|
+
<div class="chat-messages" id="chat-messages">
|
|
1306
|
+
<div class="empty-state">
|
|
1307
|
+
<div class="empty-icon">💬</div>
|
|
1308
|
+
<p>Send a message to start chatting with your CoStar agent.</p>
|
|
1309
|
+
</div>
|
|
1310
|
+
</div>
|
|
1311
|
+
<!-- VNC Live Browser Panel -->
|
|
1312
|
+
<div class="vnc-panel" id="vnc-panel">
|
|
1313
|
+
<div class="vnc-panel-header" onclick="toggleVncPanel()">
|
|
1314
|
+
<span class="vnc-panel-dot"></span>
|
|
1315
|
+
<span class="vnc-panel-title">Browser — Live View</span>
|
|
1316
|
+
<div class="vnc-panel-actions">
|
|
1317
|
+
<button class="vnc-open-btn" onclick="event.stopPropagation(); openVncExternal()" title="Open in new tab">↗ Pop out</button>
|
|
1318
|
+
</div>
|
|
1319
|
+
<span class="vnc-panel-toggle">▼</span>
|
|
1320
|
+
</div>
|
|
1321
|
+
<div class="vnc-panel-body" id="vnc-panel-body"></div>
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
<div class="compaction-banner" id="compaction-banner">
|
|
1325
|
+
<span class="compaction-icon">⚙</span>
|
|
1326
|
+
<span class="compaction-text">Compacting conversation context...</span>
|
|
1327
|
+
</div>
|
|
1328
|
+
<div class="chat-input-wrap">
|
|
1329
|
+
<input type="text" id="chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter') sendMessage()" />
|
|
1330
|
+
<button class="btn btn-primary" onclick="sendMessage()" id="chat-send-btn">Send</button>
|
|
1331
|
+
</div>
|
|
1332
|
+
</div>
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
1335
|
+
<!-- ═══ Sessions Page ═══ -->
|
|
1336
|
+
<div class="page" id="page-sessions">
|
|
1337
|
+
<!-- Session file list view -->
|
|
1338
|
+
<div class="card" id="sessions-list-view">
|
|
1339
|
+
<div class="card-header">
|
|
1340
|
+
<h3>Session Files</h3>
|
|
1341
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
1342
|
+
<span id="sessions-dir-path" style="font-size:11px;color:var(--text-muted);font-family:var(--mono);"></span>
|
|
1343
|
+
<button class="btn btn-sm" onclick="loadSessionFiles()">Refresh</button>
|
|
1344
|
+
</div>
|
|
1345
|
+
</div>
|
|
1346
|
+
<div class="table-wrap">
|
|
1347
|
+
<table>
|
|
1348
|
+
<thead>
|
|
1349
|
+
<tr>
|
|
1350
|
+
<th>File</th>
|
|
1351
|
+
<th>Size</th>
|
|
1352
|
+
<th>Last Modified</th>
|
|
1353
|
+
<th>Actions</th>
|
|
1354
|
+
</tr>
|
|
1355
|
+
</thead>
|
|
1356
|
+
<tbody id="sessions-table-body">
|
|
1357
|
+
<tr><td colspan="4"><div class="loading-center"><div class="spinner"></div></div></td></tr>
|
|
1358
|
+
</tbody>
|
|
1359
|
+
</table>
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
|
|
1363
|
+
<!-- Session viewer (hidden by default) -->
|
|
1364
|
+
<div id="sessions-viewer" style="display:none">
|
|
1365
|
+
<div class="card" style="margin-bottom:0;">
|
|
1366
|
+
<div class="card-header">
|
|
1367
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
1368
|
+
<button class="btn btn-sm" onclick="closeSessionViewer()">← Back</button>
|
|
1369
|
+
<h3 id="sessions-viewer-title" style="font-size:13px;font-family:var(--mono);margin:0;"></h3>
|
|
1370
|
+
</div>
|
|
1371
|
+
<div style="display:flex;gap:6px;align-items:center">
|
|
1372
|
+
<span id="sessions-viewer-meta" style="font-size:11px;color:var(--text-muted);"></span>
|
|
1373
|
+
<button class="btn btn-sm" id="sessions-tab-parsed" onclick="switchSessionView('parsed')" style="font-weight:600;">Conversation</button>
|
|
1374
|
+
<button class="btn btn-sm" id="sessions-tab-raw" onclick="switchSessionView('raw')">Raw JSON</button>
|
|
1375
|
+
</div>
|
|
1376
|
+
</div>
|
|
1377
|
+
</div>
|
|
1378
|
+
|
|
1379
|
+
<!-- Parsed conversation view -->
|
|
1380
|
+
<div id="sessions-parsed-view" style="max-height:70vh;overflow:auto;padding:8px 0;">
|
|
1381
|
+
<div id="sessions-parsed-content" style="display:flex;flex-direction:column;gap:2px;">
|
|
1382
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1383
|
+
</div>
|
|
1384
|
+
</div>
|
|
1385
|
+
|
|
1386
|
+
<!-- Raw JSON view -->
|
|
1387
|
+
<div id="sessions-raw-view" style="display:none;">
|
|
1388
|
+
<pre id="sessions-viewer-content" style="margin:0;padding:16px;background:var(--bg);border-radius:0 0 8px 8px;font-family:var(--mono);font-size:12px;line-height:1.5;max-height:70vh;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-secondary);"></pre>
|
|
1389
|
+
</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
|
|
1393
|
+
<!-- ═══ Cron Page ═══ -->
|
|
1394
|
+
<div class="page" id="page-cron">
|
|
1395
|
+
<div class="card">
|
|
1396
|
+
<div class="card-header">
|
|
1397
|
+
<h3>Cron Jobs</h3>
|
|
1398
|
+
<div style="display:flex;gap:8px;">
|
|
1399
|
+
<button class="btn btn-sm" onclick="loadCronJobs()">Refresh</button>
|
|
1400
|
+
<button class="btn btn-sm btn-primary" onclick="openCronModal()">+ New Job</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
<div class="table-wrap">
|
|
1404
|
+
<table>
|
|
1405
|
+
<thead>
|
|
1406
|
+
<tr>
|
|
1407
|
+
<th>Name</th>
|
|
1408
|
+
<th>Schedule</th>
|
|
1409
|
+
<th>Status</th>
|
|
1410
|
+
<th>Last Run</th>
|
|
1411
|
+
<th>Next Run</th>
|
|
1412
|
+
<th>Actions</th>
|
|
1413
|
+
</tr>
|
|
1414
|
+
</thead>
|
|
1415
|
+
<tbody id="cron-table-body">
|
|
1416
|
+
<tr><td colspan="6"><div class="loading-center"><div class="spinner"></div></div></td></tr>
|
|
1417
|
+
</tbody>
|
|
1418
|
+
</table>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>
|
|
1421
|
+
|
|
1422
|
+
<div class="card">
|
|
1423
|
+
<div class="card-header"><h3>Scheduler Status</h3></div>
|
|
1424
|
+
<div class="config-list" id="cron-status-info">
|
|
1425
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
|
|
1430
|
+
<!-- ═══ Skills Page ═══ -->
|
|
1431
|
+
<div class="page" id="page-skills">
|
|
1432
|
+
<div class="skill-grid" id="skills-grid">
|
|
1433
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1434
|
+
</div>
|
|
1435
|
+
</div>
|
|
1436
|
+
|
|
1437
|
+
<!-- ═══ Workspace Page ═══ -->
|
|
1438
|
+
<div class="page" id="page-workspace">
|
|
1439
|
+
<div class="workspace-split">
|
|
1440
|
+
<div class="card workspace-list" id="workspace-file-list">
|
|
1441
|
+
<div class="card-header"><h3>Files</h3></div>
|
|
1442
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1443
|
+
</div>
|
|
1444
|
+
<div class="workspace-editor">
|
|
1445
|
+
<div class="editor-toolbar">
|
|
1446
|
+
<span id="editor-filename" style="font-weight:600;font-size:14px;">No file selected</span>
|
|
1447
|
+
<div style="display:flex;gap:8px;">
|
|
1448
|
+
<button class="btn btn-sm btn-primary" id="save-file-btn" onclick="saveWorkspaceFile()" disabled>Save</button>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
<textarea id="workspace-editor-area" placeholder="Select a file to edit..." disabled></textarea>
|
|
1452
|
+
</div>
|
|
1453
|
+
</div>
|
|
1454
|
+
</div>
|
|
1455
|
+
|
|
1456
|
+
<!-- ═══ Logs Page ═══ -->
|
|
1457
|
+
<div class="page" id="page-logs">
|
|
1458
|
+
<div class="log-toolbar">
|
|
1459
|
+
<select id="log-component-filter" onchange="loadLogs()">
|
|
1460
|
+
<option value="">All Components</option>
|
|
1461
|
+
</select>
|
|
1462
|
+
<select id="log-level-filter" onchange="loadLogs()">
|
|
1463
|
+
<option value="">All Levels</option>
|
|
1464
|
+
<option value="info">Info</option>
|
|
1465
|
+
<option value="warn">Warn</option>
|
|
1466
|
+
<option value="error">Error</option>
|
|
1467
|
+
<option value="debug">Debug</option>
|
|
1468
|
+
</select>
|
|
1469
|
+
<button class="btn btn-sm" onclick="loadLogs()">Refresh</button>
|
|
1470
|
+
<label style="display:flex;align-items:center;gap:6px;margin-bottom:0;cursor:pointer;">
|
|
1471
|
+
<input type="checkbox" id="log-stream-toggle" onchange="toggleLogStream()" checked />
|
|
1472
|
+
<span style="font-size:13px;">Live</span>
|
|
1473
|
+
</label>
|
|
1474
|
+
<button class="btn btn-sm btn-danger" onclick="clearLogView()" style="margin-left:auto;">Clear View</button>
|
|
1475
|
+
</div>
|
|
1476
|
+
<div class="log-entries" id="log-entries">
|
|
1477
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1478
|
+
</div>
|
|
1479
|
+
</div>
|
|
1480
|
+
|
|
1481
|
+
<!-- ═══ Settings Page ═══ -->
|
|
1482
|
+
<div class="page" id="page-settings">
|
|
1483
|
+
<div class="card">
|
|
1484
|
+
<div class="card-header">
|
|
1485
|
+
<h3>Environment Variables</h3>
|
|
1486
|
+
<div style="display:flex;gap:8px;">
|
|
1487
|
+
<button class="btn btn-sm" onclick="loadEnvVariables()">Refresh</button>
|
|
1488
|
+
<button class="btn btn-sm btn-primary" onclick="openEnvModal()">+ Add Variable</button>
|
|
1489
|
+
</div>
|
|
1490
|
+
</div>
|
|
1491
|
+
<div class="table-wrap">
|
|
1492
|
+
<table>
|
|
1493
|
+
<thead>
|
|
1494
|
+
<tr>
|
|
1495
|
+
<th>Key</th>
|
|
1496
|
+
<th>Value</th>
|
|
1497
|
+
<th>Actions</th>
|
|
1498
|
+
</tr>
|
|
1499
|
+
</thead>
|
|
1500
|
+
<tbody id="env-table-body">
|
|
1501
|
+
<tr><td colspan="3"><div class="loading-center"><div class="spinner"></div></div></td></tr>
|
|
1502
|
+
</tbody>
|
|
1503
|
+
</table>
|
|
1504
|
+
</div>
|
|
1505
|
+
</div>
|
|
1506
|
+
|
|
1507
|
+
<div class="card">
|
|
1508
|
+
<div class="card-header">
|
|
1509
|
+
<h3>Configuration</h3>
|
|
1510
|
+
<button class="btn btn-sm" onclick="loadSettings()">Refresh</button>
|
|
1511
|
+
</div>
|
|
1512
|
+
<div class="config-list" id="settings-config">
|
|
1513
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
1514
|
+
</div>
|
|
1515
|
+
</div>
|
|
1516
|
+
|
|
1517
|
+
<div class="card">
|
|
1518
|
+
<div class="card-header"><h3>Paths</h3></div>
|
|
1519
|
+
<div class="config-list" id="settings-paths"></div>
|
|
1520
|
+
</div>
|
|
1521
|
+
</div>
|
|
1522
|
+
</div>
|
|
1523
|
+
</main>
|
|
1524
|
+
</div>
|
|
1525
|
+
|
|
1526
|
+
<!-- Cron Modal -->
|
|
1527
|
+
<div class="modal-overlay" id="cron-modal">
|
|
1528
|
+
<div class="modal">
|
|
1529
|
+
<h3 id="cron-modal-title">New Cron Job</h3>
|
|
1530
|
+
<div class="form-group">
|
|
1531
|
+
<label>Name</label>
|
|
1532
|
+
<input type="text" id="cron-name" placeholder="e.g. daily-news-summary" />
|
|
1533
|
+
</div>
|
|
1534
|
+
<div class="form-group">
|
|
1535
|
+
<label>Task / Instruction</label>
|
|
1536
|
+
<textarea id="cron-task" placeholder="What should the agent do?"></textarea>
|
|
1537
|
+
</div>
|
|
1538
|
+
<div class="form-group">
|
|
1539
|
+
<label>Schedule Type</label>
|
|
1540
|
+
<select id="cron-schedule-type" onchange="toggleCronScheduleFields()">
|
|
1541
|
+
<option value="every">Every (interval)</option>
|
|
1542
|
+
<option value="cron">Cron Expression</option>
|
|
1543
|
+
<option value="at">At (one-time)</option>
|
|
1544
|
+
</select>
|
|
1545
|
+
</div>
|
|
1546
|
+
<div class="form-group" id="cron-every-group">
|
|
1547
|
+
<label>Interval</label>
|
|
1548
|
+
<input type="text" id="cron-every" placeholder="e.g. 1h, 30m, 6h" />
|
|
1549
|
+
</div>
|
|
1550
|
+
<div class="form-group" id="cron-expression-group" style="display:none;">
|
|
1551
|
+
<label>Cron Expression</label>
|
|
1552
|
+
<input type="text" id="cron-expression" placeholder="e.g. 0 9 * * *" />
|
|
1553
|
+
</div>
|
|
1554
|
+
<div class="form-group" id="cron-at-group" style="display:none;">
|
|
1555
|
+
<label>Run At (ISO datetime)</label>
|
|
1556
|
+
<input type="text" id="cron-at" placeholder="e.g. 2026-02-07T09:00:00Z" />
|
|
1557
|
+
</div>
|
|
1558
|
+
<div class="modal-actions">
|
|
1559
|
+
<button class="btn" onclick="closeCronModal()">Cancel</button>
|
|
1560
|
+
<button class="btn btn-primary" onclick="saveCronJob()">Save</button>
|
|
1561
|
+
</div>
|
|
1562
|
+
</div>
|
|
1563
|
+
</div>
|
|
1564
|
+
|
|
1565
|
+
<!-- Skill Detail Modal -->
|
|
1566
|
+
<div class="modal-overlay" id="skill-modal">
|
|
1567
|
+
<div class="modal" style="max-width:800px;">
|
|
1568
|
+
<h3 id="skill-modal-title">Skill Details</h3>
|
|
1569
|
+
<div id="skill-modal-content"></div>
|
|
1570
|
+
<div class="modal-actions">
|
|
1571
|
+
<button class="btn" onclick="closeSkillModal()">Close</button>
|
|
1572
|
+
</div>
|
|
1573
|
+
</div>
|
|
1574
|
+
</div>
|
|
1575
|
+
|
|
1576
|
+
<!-- Environment Variable Modal -->
|
|
1577
|
+
<div class="modal-overlay" id="env-modal">
|
|
1578
|
+
<div class="modal">
|
|
1579
|
+
<h3 id="env-modal-title">Add Environment Variable</h3>
|
|
1580
|
+
<div class="form-group">
|
|
1581
|
+
<label>Key (UPPERCASE_WITH_UNDERSCORES)</label>
|
|
1582
|
+
<input type="text" id="env-key" placeholder="e.g. MY_API_KEY" style="text-transform:uppercase;" />
|
|
1583
|
+
</div>
|
|
1584
|
+
<div class="form-group">
|
|
1585
|
+
<label>Value</label>
|
|
1586
|
+
<input type="text" id="env-value" placeholder="Enter value..." />
|
|
1587
|
+
</div>
|
|
1588
|
+
<div class="modal-actions">
|
|
1589
|
+
<button class="btn" onclick="closeEnvModal()">Cancel</button>
|
|
1590
|
+
<button class="btn btn-primary" onclick="saveEnvVariable()">Save</button>
|
|
1591
|
+
</div>
|
|
1592
|
+
</div>
|
|
1593
|
+
</div>
|
|
1594
|
+
|
|
1595
|
+
<script>
|
|
1596
|
+
/* ═══════════════════════════════════════════════
|
|
1597
|
+
* CoStar Dashboard - Client-side JS
|
|
1598
|
+
* ═══════════════════════════════════════════════ */
|
|
1599
|
+
|
|
1600
|
+
const API = ''; // same origin
|
|
1601
|
+
let currentPage = 'dashboard';
|
|
1602
|
+
let chatSessionId = null;
|
|
1603
|
+
let logEventSource = null;
|
|
1604
|
+
let currentWorkspaceFile = null;
|
|
1605
|
+
|
|
1606
|
+
// ─── Theme ────────────────────────────────────────
|
|
1607
|
+
function initTheme() {
|
|
1608
|
+
const saved = localStorage.getItem('costar-theme');
|
|
1609
|
+
const theme = saved || 'dark';
|
|
1610
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
1611
|
+
updateThemeIcon(theme);
|
|
1612
|
+
}
|
|
1613
|
+
function toggleTheme() {
|
|
1614
|
+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
1615
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
1616
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
1617
|
+
localStorage.setItem('costar-theme', next);
|
|
1618
|
+
updateThemeIcon(next);
|
|
1619
|
+
}
|
|
1620
|
+
function updateThemeIcon(theme) {
|
|
1621
|
+
const btn = document.getElementById('theme-toggle');
|
|
1622
|
+
if (btn) btn.innerHTML = theme === 'dark' ? '☼' : '☾';
|
|
1623
|
+
}
|
|
1624
|
+
initTheme();
|
|
1625
|
+
|
|
1626
|
+
// ─── Navigation ──────────────────────────────────
|
|
1627
|
+
function navigate(page) {
|
|
1628
|
+
currentPage = page;
|
|
1629
|
+
|
|
1630
|
+
// Update nav
|
|
1631
|
+
document.querySelectorAll('.nav-item').forEach(el => {
|
|
1632
|
+
el.classList.toggle('active', el.dataset.page === page);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
// Update pages
|
|
1636
|
+
document.querySelectorAll('.page').forEach(el => {
|
|
1637
|
+
el.classList.toggle('active', el.id === `page-${page}`);
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// Update title
|
|
1641
|
+
const titles = {
|
|
1642
|
+
dashboard: 'Dashboard',
|
|
1643
|
+
chat: 'Chat',
|
|
1644
|
+
sessions: 'Sessions',
|
|
1645
|
+
cron: 'Cron Jobs',
|
|
1646
|
+
skills: 'Skills',
|
|
1647
|
+
workspace: 'Workspace',
|
|
1648
|
+
logs: 'Logs',
|
|
1649
|
+
settings: 'Settings',
|
|
1650
|
+
};
|
|
1651
|
+
document.getElementById('page-title').textContent = titles[page] || page;
|
|
1652
|
+
|
|
1653
|
+
// Load page data
|
|
1654
|
+
switch (page) {
|
|
1655
|
+
case 'dashboard': loadDashboard(); break;
|
|
1656
|
+
case 'chat': initChat(); break;
|
|
1657
|
+
case 'sessions': loadSessionFiles(); startSessionPolling(); break;
|
|
1658
|
+
case 'cron': loadCronJobs(); loadCronStatus(); break;
|
|
1659
|
+
case 'skills': loadSkills(); break;
|
|
1660
|
+
case 'workspace': loadWorkspaceFiles(); break;
|
|
1661
|
+
case 'logs': loadLogs(); startLogStream(); break;
|
|
1662
|
+
case 'settings': loadSettings(); break;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// ─── API helper ──────────────────────────────────
|
|
1667
|
+
async function api(path, opts = {}) {
|
|
1668
|
+
const res = await fetch(API + path, {
|
|
1669
|
+
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
|
1670
|
+
...opts,
|
|
1671
|
+
});
|
|
1672
|
+
if (!res.ok) {
|
|
1673
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
1674
|
+
throw new Error(err.error || res.statusText);
|
|
1675
|
+
}
|
|
1676
|
+
return res.json();
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// ─── Dashboard ───────────────────────────────────
|
|
1680
|
+
async function loadDashboard() {
|
|
1681
|
+
try {
|
|
1682
|
+
const [status, versionInfo] = await Promise.all([
|
|
1683
|
+
api('/api/status'),
|
|
1684
|
+
api('/api/version').catch(() => null),
|
|
1685
|
+
]);
|
|
1686
|
+
const a = status.agent;
|
|
1687
|
+
|
|
1688
|
+
// Show version in sidebar
|
|
1689
|
+
if (versionInfo?.version) {
|
|
1690
|
+
document.getElementById('sidebar-version').textContent = `v${versionInfo.version}`;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Update banner — check for newer version on npm
|
|
1694
|
+
renderUpdateBanner(versionInfo);
|
|
1695
|
+
|
|
1696
|
+
// Stats
|
|
1697
|
+
const uptime = formatUptime(a.uptime);
|
|
1698
|
+
document.getElementById('stat-uptime').textContent = uptime;
|
|
1699
|
+
document.getElementById('stat-platform').textContent = `${a.platform} / ${a.arch} / ${a.nodeVersion}`;
|
|
1700
|
+
|
|
1701
|
+
const cronStatus = status.cron;
|
|
1702
|
+
document.getElementById('stat-cron').textContent = cronStatus.jobsExecuted || '0';
|
|
1703
|
+
document.getElementById('stat-cron-sub').textContent = cronStatus.isRunning ? 'Scheduler running' : 'Scheduler stopped';
|
|
1704
|
+
|
|
1705
|
+
document.getElementById('stat-skills').textContent = status.skills.eligible;
|
|
1706
|
+
document.getElementById('stat-skills-sub').textContent = `${status.skills.total} total loaded`;
|
|
1707
|
+
|
|
1708
|
+
const heapMB = (a.memoryUsage.heapUsed / 1024 / 1024).toFixed(0);
|
|
1709
|
+
const heapTotalMB = (a.memoryUsage.heapTotal / 1024 / 1024).toFixed(0);
|
|
1710
|
+
document.getElementById('stat-memory').textContent = `${heapMB} MB`;
|
|
1711
|
+
document.getElementById('stat-memory-sub').textContent = `${heapTotalMB} MB heap total`;
|
|
1712
|
+
|
|
1713
|
+
// System info
|
|
1714
|
+
const info = document.getElementById('system-info');
|
|
1715
|
+
info.innerHTML = [
|
|
1716
|
+
row('Version', versionInfo?.version ? `v${versionInfo.version}` : '--'),
|
|
1717
|
+
row('Auto-Update', versionInfo?.autoUpdateEnabled ? 'Enabled' : 'Disabled'),
|
|
1718
|
+
row('User ID', a.userId),
|
|
1719
|
+
row('Workspace', a.workspaceDir),
|
|
1720
|
+
row('Node', a.nodeVersion),
|
|
1721
|
+
row('Platform', `${a.platform} ${a.arch}`),
|
|
1722
|
+
row('Heartbeat', status.heartbeat.enabled ? 'Enabled' : 'Disabled'),
|
|
1723
|
+
row('Cron Scheduler', cronStatus.isRunning ? 'Running' : 'Stopped'),
|
|
1724
|
+
row('Cron Last Check', cronStatus.lastCheckAt ? new Date(cronStatus.lastCheckAt).toLocaleString() : 'Never'),
|
|
1725
|
+
].join('');
|
|
1726
|
+
// Load session info alongside dashboard
|
|
1727
|
+
loadSessionInfo();
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
document.getElementById('system-info').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// ─── Agent Session Management ─────────────────────
|
|
1734
|
+
async function loadSessionInfo() {
|
|
1735
|
+
try {
|
|
1736
|
+
const info = await api('/api/agent/session');
|
|
1737
|
+
const el = document.getElementById('session-info');
|
|
1738
|
+
if (!el) return;
|
|
1739
|
+
|
|
1740
|
+
el.innerHTML = [
|
|
1741
|
+
row('Session ID', info.sessionId),
|
|
1742
|
+
row('Session File', info.exists ? 'Exists' : 'Not created yet'),
|
|
1743
|
+
row('File Size', info.exists ? `${info.sizeKB} KB (${info.sizeBytes.toLocaleString()} bytes)` : '--'),
|
|
1744
|
+
row('Conversation Length', `${info.conversationLength} messages`),
|
|
1745
|
+
row('Status', info.isBusy ? '⏳ Busy' : '✅ Idle'),
|
|
1746
|
+
].join('');
|
|
1747
|
+
|
|
1748
|
+
// Disable buttons if busy
|
|
1749
|
+
const compactBtn = document.getElementById('compact-btn');
|
|
1750
|
+
const resetBtn = document.getElementById('reset-session-btn');
|
|
1751
|
+
if (compactBtn) compactBtn.disabled = info.isBusy;
|
|
1752
|
+
if (resetBtn) resetBtn.disabled = info.isBusy;
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
const el = document.getElementById('session-info');
|
|
1755
|
+
if (el) el.innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
async function triggerCompaction() {
|
|
1760
|
+
const btn = document.getElementById('compact-btn');
|
|
1761
|
+
if (!btn) return;
|
|
1762
|
+
|
|
1763
|
+
if (!confirm('Compact the agent session? This will summarize older messages to reduce context size.')) return;
|
|
1764
|
+
|
|
1765
|
+
btn.disabled = true;
|
|
1766
|
+
const origText = btn.innerHTML;
|
|
1767
|
+
btn.innerHTML = '⏳ Compacting...';
|
|
1768
|
+
|
|
1769
|
+
try {
|
|
1770
|
+
const result = await api('/api/agent/compact', {
|
|
1771
|
+
method: 'POST',
|
|
1772
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
if (result.compacted) {
|
|
1776
|
+
const before = result.result?.tokensBefore?.toLocaleString() ?? '?';
|
|
1777
|
+
const after = result.result?.tokensAfter?.toLocaleString() ?? '?';
|
|
1778
|
+
alert(`Compaction successful!\n\nTokens: ${before} → ${after}`);
|
|
1779
|
+
} else {
|
|
1780
|
+
alert(`Compaction did not reduce size: ${result.reason || 'Unknown reason'}`);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Refresh session info
|
|
1784
|
+
await loadSessionInfo();
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
alert(`Compaction failed: ${err.message}`);
|
|
1787
|
+
} finally {
|
|
1788
|
+
btn.disabled = false;
|
|
1789
|
+
btn.innerHTML = origText;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
async function triggerResetSession() {
|
|
1794
|
+
const btn = document.getElementById('reset-session-btn');
|
|
1795
|
+
if (!btn) return;
|
|
1796
|
+
|
|
1797
|
+
if (!confirm('⚠️ Reset the agent session?\n\nThis will delete the conversation history. The agent will start fresh on the next turn.\n\nThis action cannot be undone.')) return;
|
|
1798
|
+
|
|
1799
|
+
btn.disabled = true;
|
|
1800
|
+
const origText = btn.innerHTML;
|
|
1801
|
+
btn.innerHTML = '⏳ Resetting...';
|
|
1802
|
+
|
|
1803
|
+
try {
|
|
1804
|
+
const result = await api('/api/agent/reset-session', {
|
|
1805
|
+
method: 'POST',
|
|
1806
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
if (result.success) {
|
|
1810
|
+
alert('Session reset successfully. The agent will start fresh on the next turn.');
|
|
1811
|
+
} else {
|
|
1812
|
+
alert(`Reset failed: ${result.error || 'Unknown error'}`);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// Refresh session info
|
|
1816
|
+
await loadSessionInfo();
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
alert(`Reset failed: ${err.message}`);
|
|
1819
|
+
} finally {
|
|
1820
|
+
btn.disabled = false;
|
|
1821
|
+
btn.innerHTML = origText;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ─── Update Banner ────────────────────────────────
|
|
1826
|
+
async function renderUpdateBanner(versionInfo) {
|
|
1827
|
+
const container = document.getElementById('update-banner-container');
|
|
1828
|
+
if (!container) return;
|
|
1829
|
+
|
|
1830
|
+
// Always show version + manual update button
|
|
1831
|
+
if (!versionInfo) {
|
|
1832
|
+
container.innerHTML = '';
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const v = esc(versionInfo.version || 'unknown');
|
|
1837
|
+
const autoUpdate = versionInfo.autoUpdateEnabled;
|
|
1838
|
+
const updating = versionInfo.updateInProgress;
|
|
1839
|
+
|
|
1840
|
+
container.innerHTML = `
|
|
1841
|
+
<div class="update-banner">
|
|
1842
|
+
<div class="update-info">
|
|
1843
|
+
Running <strong>v${v}</strong>
|
|
1844
|
+
<small>${autoUpdate ? 'Auto-update enabled — checks every 24h' : 'Auto-update disabled'}</small>
|
|
1845
|
+
</div>
|
|
1846
|
+
<button class="update-btn" id="manual-update-btn" onclick="triggerManualUpdate()" ${updating ? 'disabled' : ''}>
|
|
1847
|
+
${updating ? 'Updating...' : 'Update Now'}
|
|
1848
|
+
</button>
|
|
1849
|
+
</div>
|
|
1850
|
+
`;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
async function triggerManualUpdate() {
|
|
1854
|
+
const btn = document.getElementById('manual-update-btn');
|
|
1855
|
+
if (!btn) return;
|
|
1856
|
+
|
|
1857
|
+
btn.disabled = true;
|
|
1858
|
+
btn.textContent = 'Updating...';
|
|
1859
|
+
|
|
1860
|
+
try {
|
|
1861
|
+
const result = await api('/api/update', {
|
|
1862
|
+
method: 'POST',
|
|
1863
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1864
|
+
body: JSON.stringify({ version: 'latest' }),
|
|
1865
|
+
});
|
|
1866
|
+
btn.textContent = 'Restarting...';
|
|
1867
|
+
// The server will restart, so the page will lose connection
|
|
1868
|
+
setTimeout(() => {
|
|
1869
|
+
btn.textContent = 'Reconnecting...';
|
|
1870
|
+
// Try to reload after a delay to reconnect to the new version
|
|
1871
|
+
setTimeout(() => location.reload(), 8000);
|
|
1872
|
+
}, 5000);
|
|
1873
|
+
} catch (err) {
|
|
1874
|
+
btn.disabled = false;
|
|
1875
|
+
btn.textContent = 'Update Failed — Retry';
|
|
1876
|
+
console.error('Update failed:', err);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function row(key, value) {
|
|
1881
|
+
return `<div class="config-row"><span class="config-key">${esc(key)}</span><span class="config-value">${esc(String(value || '--'))}</span></div>`;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function formatUptime(seconds) {
|
|
1885
|
+
const d = Math.floor(seconds / 86400);
|
|
1886
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
1887
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
1888
|
+
if (d > 0) return `${d}d ${h}h`;
|
|
1889
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
1890
|
+
return `${m}m`;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// ─── VNC Live Browser Panel ─────────────────────
|
|
1894
|
+
let vncUrl = null;
|
|
1895
|
+
let vncPanelCollapsed = false;
|
|
1896
|
+
|
|
1897
|
+
/** Build VNC URL using the current page's hostname (not 127.0.0.1) so it works on remote sandboxes. */
|
|
1898
|
+
function buildVncUrl(port) {
|
|
1899
|
+
const host = window.location.hostname || '127.0.0.1';
|
|
1900
|
+
return 'http://' + host + ':' + port + '/vnc.html?autoconnect=1&resize=remote';
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function showVncPanel(url) {
|
|
1904
|
+
vncUrl = url;
|
|
1905
|
+
const panel = document.getElementById('vnc-panel');
|
|
1906
|
+
const body = document.getElementById('vnc-panel-body');
|
|
1907
|
+
if (!panel || !body) return;
|
|
1908
|
+
if (!body.querySelector('iframe') || body.querySelector('iframe').src !== url) {
|
|
1909
|
+
body.innerHTML = '<iframe src="' + esc(url) + '" allow="clipboard-read; clipboard-write"></iframe>';
|
|
1910
|
+
}
|
|
1911
|
+
panel.classList.add('active');
|
|
1912
|
+
panel.classList.remove('collapsed');
|
|
1913
|
+
vncPanelCollapsed = false;
|
|
1914
|
+
scrollChat();
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function hideVncPanel() {
|
|
1918
|
+
vncUrl = null;
|
|
1919
|
+
const panel = document.getElementById('vnc-panel');
|
|
1920
|
+
const body = document.getElementById('vnc-panel-body');
|
|
1921
|
+
if (!panel) return;
|
|
1922
|
+
panel.classList.remove('active');
|
|
1923
|
+
if (body) body.innerHTML = '';
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function toggleVncPanel() {
|
|
1927
|
+
const panel = document.getElementById('vnc-panel');
|
|
1928
|
+
if (!panel) return;
|
|
1929
|
+
vncPanelCollapsed = !vncPanelCollapsed;
|
|
1930
|
+
panel.classList.toggle('collapsed', vncPanelCollapsed);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function openVncExternal() {
|
|
1934
|
+
if (vncUrl) window.open(vncUrl, '_blank', 'width=1280,height=900');
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function checkBrowserVnc() {
|
|
1938
|
+
try {
|
|
1939
|
+
const data = await api('/api/browser/vnc');
|
|
1940
|
+
if (data.vncPort) {
|
|
1941
|
+
showVncPanel(buildVncUrl(data.vncPort));
|
|
1942
|
+
} else if (data.vncUrl) {
|
|
1943
|
+
showVncPanel(data.vncUrl);
|
|
1944
|
+
}
|
|
1945
|
+
} catch { /* ignore */ }
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// ─── Chat (Real-time Streaming) ─────────────────
|
|
1949
|
+
let chatStreaming = false;
|
|
1950
|
+
|
|
1951
|
+
async function initChat() {
|
|
1952
|
+
if (!chatSessionId) {
|
|
1953
|
+
try {
|
|
1954
|
+
const session = await api('/api/sessions', { method: 'POST' });
|
|
1955
|
+
chatSessionId = session.id;
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
console.error('Failed to create chat session:', err);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
checkBrowserVnc();
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
async function sendMessage() {
|
|
1964
|
+
const input = document.getElementById('chat-input');
|
|
1965
|
+
const msg = input.value.trim();
|
|
1966
|
+
if (!msg || chatStreaming) return;
|
|
1967
|
+
|
|
1968
|
+
if (!chatSessionId) await initChat();
|
|
1969
|
+
|
|
1970
|
+
input.value = '';
|
|
1971
|
+
const messagesEl = document.getElementById('chat-messages');
|
|
1972
|
+
|
|
1973
|
+
// Clear empty state
|
|
1974
|
+
const emptyState = messagesEl.querySelector('.empty-state');
|
|
1975
|
+
if (emptyState) emptyState.remove();
|
|
1976
|
+
|
|
1977
|
+
// Add user message
|
|
1978
|
+
messagesEl.insertAdjacentHTML('beforeend', chatMsgHTML('user', msg));
|
|
1979
|
+
scrollChat();
|
|
1980
|
+
|
|
1981
|
+
// Create streaming assistant message container
|
|
1982
|
+
// Single flow container — text and tool cards interleave in arrival order
|
|
1983
|
+
const streamId = 'stream-' + Date.now();
|
|
1984
|
+
messagesEl.insertAdjacentHTML('beforeend', `
|
|
1985
|
+
<div id="${streamId}" class="chat-msg assistant">
|
|
1986
|
+
<div class="avatar">C</div>
|
|
1987
|
+
<div class="msg-content">
|
|
1988
|
+
<div class="msg-role">CoStar</div>
|
|
1989
|
+
<div id="${streamId}-flow" class="msg-flow"></div>
|
|
1990
|
+
<div id="${streamId}-meta" class="chat-meta" style="display:none;"></div>
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
`);
|
|
1994
|
+
scrollChat();
|
|
1995
|
+
|
|
1996
|
+
// Disable send
|
|
1997
|
+
const sendBtn = document.getElementById('chat-send-btn');
|
|
1998
|
+
sendBtn.disabled = true;
|
|
1999
|
+
sendBtn.textContent = 'Thinking...';
|
|
2000
|
+
chatStreaming = true;
|
|
2001
|
+
|
|
2002
|
+
try {
|
|
2003
|
+
await streamChat(chatSessionId, msg, streamId);
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
const flowEl = document.getElementById(`${streamId}-flow`);
|
|
2006
|
+
if (flowEl) {
|
|
2007
|
+
flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
|
|
2008
|
+
flowEl.insertAdjacentHTML('beforeend',
|
|
2009
|
+
`<div style="color:var(--red);margin-top:8px;">Error: ${esc(err.message)}</div>`);
|
|
2010
|
+
}
|
|
2011
|
+
} finally {
|
|
2012
|
+
sendBtn.disabled = false;
|
|
2013
|
+
sendBtn.textContent = 'Send';
|
|
2014
|
+
chatStreaming = false;
|
|
2015
|
+
// Remove any remaining streaming cursors
|
|
2016
|
+
const flowEl = document.getElementById(`${streamId}-flow`);
|
|
2017
|
+
if (flowEl) flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function streamChat(sessionId, message, streamId) {
|
|
2022
|
+
return new Promise((resolve, reject) => {
|
|
2023
|
+
const flowEl = document.getElementById(`${streamId}-flow`);
|
|
2024
|
+
const metaEl = document.getElementById(`${streamId}-meta`);
|
|
2025
|
+
let fullText = '';
|
|
2026
|
+
let toolCount = 0;
|
|
2027
|
+
|
|
2028
|
+
// Track the current text span — a new one is created after each tool card
|
|
2029
|
+
let currentTextSpan = null;
|
|
2030
|
+
|
|
2031
|
+
function ensureTextSpan() {
|
|
2032
|
+
if (!currentTextSpan) {
|
|
2033
|
+
currentTextSpan = document.createElement('div');
|
|
2034
|
+
currentTextSpan.className = 'msg-text-segment streaming-cursor';
|
|
2035
|
+
flowEl.appendChild(currentTextSpan);
|
|
2036
|
+
}
|
|
2037
|
+
return currentTextSpan;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Create initial text span
|
|
2041
|
+
ensureTextSpan();
|
|
2042
|
+
|
|
2043
|
+
// Use fetch + ReadableStream for POST-based SSE
|
|
2044
|
+
fetch(`/api/sessions/${sessionId}/messages/stream`, {
|
|
2045
|
+
method: 'POST',
|
|
2046
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2047
|
+
body: JSON.stringify({ message }),
|
|
2048
|
+
}).then(response => {
|
|
2049
|
+
if (!response.ok) {
|
|
2050
|
+
throw new Error(`HTTP ${response.status}`);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const reader = response.body.getReader();
|
|
2054
|
+
const decoder = new TextDecoder();
|
|
2055
|
+
let buffer = '';
|
|
2056
|
+
|
|
2057
|
+
function processChunk(chunk) {
|
|
2058
|
+
buffer += chunk;
|
|
2059
|
+
const lines = buffer.split('\n');
|
|
2060
|
+
buffer = lines.pop() || '';
|
|
2061
|
+
|
|
2062
|
+
let eventType = '';
|
|
2063
|
+
let eventData = '';
|
|
2064
|
+
|
|
2065
|
+
for (const line of lines) {
|
|
2066
|
+
if (line.startsWith('event: ')) {
|
|
2067
|
+
eventType = line.slice(7).trim();
|
|
2068
|
+
} else if (line.startsWith('data: ')) {
|
|
2069
|
+
eventData = line.slice(6);
|
|
2070
|
+
if (eventType && eventData) {
|
|
2071
|
+
handleSSEEvent(eventType, eventData, flowEl, metaEl, streamId, {
|
|
2072
|
+
fullText: () => fullText,
|
|
2073
|
+
setFullText: (t) => { fullText = t; },
|
|
2074
|
+
toolCount: () => toolCount,
|
|
2075
|
+
incToolCount: () => { toolCount++; },
|
|
2076
|
+
ensureTextSpan,
|
|
2077
|
+
breakTextSpan: () => {
|
|
2078
|
+
// Remove cursor from current span and start a new one after the next tool card
|
|
2079
|
+
if (currentTextSpan) currentTextSpan.classList.remove('streaming-cursor');
|
|
2080
|
+
currentTextSpan = null;
|
|
2081
|
+
},
|
|
2082
|
+
});
|
|
2083
|
+
eventType = '';
|
|
2084
|
+
eventData = '';
|
|
2085
|
+
}
|
|
2086
|
+
} else if (line === '') {
|
|
2087
|
+
eventType = '';
|
|
2088
|
+
eventData = '';
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function pump() {
|
|
2094
|
+
return reader.read().then(({ done, value }) => {
|
|
2095
|
+
if (done) {
|
|
2096
|
+
if (buffer.trim()) processChunk('\n');
|
|
2097
|
+
resolve();
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
processChunk(decoder.decode(value, { stream: true }));
|
|
2101
|
+
return pump();
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
return pump();
|
|
2106
|
+
}).catch(reject);
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/**
|
|
2111
|
+
* Tracks per-text-segment accumulated text so we can re-render each segment
|
|
2112
|
+
* independently when new deltas arrive (without re-parsing the entire response).
|
|
2113
|
+
*/
|
|
2114
|
+
const segmentTexts = new Map(); // segmentEl → accumulated text for that segment
|
|
2115
|
+
|
|
2116
|
+
function handleSSEEvent(type, dataStr, flowEl, metaEl, streamId, state) {
|
|
2117
|
+
let data;
|
|
2118
|
+
try { data = JSON.parse(dataStr); } catch { return; }
|
|
2119
|
+
|
|
2120
|
+
switch (type) {
|
|
2121
|
+
case 'connected':
|
|
2122
|
+
break;
|
|
2123
|
+
|
|
2124
|
+
case 'text_delta':
|
|
2125
|
+
if (data.text && flowEl) {
|
|
2126
|
+
state.setFullText(state.fullText() + data.text);
|
|
2127
|
+
const span = state.ensureTextSpan();
|
|
2128
|
+
const prev = segmentTexts.get(span) || '';
|
|
2129
|
+
const updated = prev + data.text;
|
|
2130
|
+
segmentTexts.set(span, updated);
|
|
2131
|
+
span.innerHTML = formatMessage(updated);
|
|
2132
|
+
scrollChat();
|
|
2133
|
+
}
|
|
2134
|
+
break;
|
|
2135
|
+
|
|
2136
|
+
case 'tool_start':
|
|
2137
|
+
if (flowEl) {
|
|
2138
|
+
// Break the current text span so the tool card appears after existing text
|
|
2139
|
+
state.breakTextSpan();
|
|
2140
|
+
state.incToolCount();
|
|
2141
|
+
const cardId = `${streamId}-tool-${data.toolCallId || state.toolCount()}`;
|
|
2142
|
+
const argsStr = data.args ? JSON.stringify(data.args, null, 2) : '{}';
|
|
2143
|
+
|
|
2144
|
+
flowEl.insertAdjacentHTML('beforeend', `
|
|
2145
|
+
<div class="tool-card" id="${cardId}" onclick="toggleToolCard('${cardId}')">
|
|
2146
|
+
<div class="tool-card-header">
|
|
2147
|
+
<span class="tool-icon running">⚙</span>
|
|
2148
|
+
<span class="tool-name">${esc(data.name || 'tool')}</span>
|
|
2149
|
+
<span class="tool-status">running...</span>
|
|
2150
|
+
<span class="tool-chevron">▶</span>
|
|
2151
|
+
</div>
|
|
2152
|
+
<div class="tool-card-body">
|
|
2153
|
+
<div class="tool-section">
|
|
2154
|
+
<div class="tool-section-label">Arguments</div>
|
|
2155
|
+
<pre>${esc(argsStr)}</pre>
|
|
2156
|
+
</div>
|
|
2157
|
+
<div class="tool-section" id="${cardId}-result" style="display:none;">
|
|
2158
|
+
<div class="tool-section-label">Result</div>
|
|
2159
|
+
<pre></pre>
|
|
2160
|
+
</div>
|
|
2161
|
+
</div>
|
|
2162
|
+
</div>
|
|
2163
|
+
`);
|
|
2164
|
+
|
|
2165
|
+
// Start a new text span after the tool card for subsequent text
|
|
2166
|
+
state.ensureTextSpan();
|
|
2167
|
+
scrollChat();
|
|
2168
|
+
|
|
2169
|
+
// Update send button with tool count
|
|
2170
|
+
const sendBtn = document.getElementById('chat-send-btn');
|
|
2171
|
+
sendBtn.textContent = `Tool ${state.toolCount()}...`;
|
|
2172
|
+
}
|
|
2173
|
+
break;
|
|
2174
|
+
|
|
2175
|
+
case 'tool_end': {
|
|
2176
|
+
const cardId = `${streamId}-tool-${data.toolCallId || state.toolCount()}`;
|
|
2177
|
+
const card = document.getElementById(cardId);
|
|
2178
|
+
if (card) {
|
|
2179
|
+
// Update icon — stop spinning
|
|
2180
|
+
const icon = card.querySelector('.tool-icon');
|
|
2181
|
+
if (icon) {
|
|
2182
|
+
icon.classList.remove('running');
|
|
2183
|
+
icon.innerHTML = data.isError ? '❌' : '✅';
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Update status
|
|
2187
|
+
const status = card.querySelector('.tool-status');
|
|
2188
|
+
if (status) {
|
|
2189
|
+
const durationStr = data.durationMs ? ` (${formatDurationShort(data.durationMs)})` : '';
|
|
2190
|
+
status.textContent = data.isError ? `error${durationStr}` : `done${durationStr}`;
|
|
2191
|
+
status.className = `tool-status ${data.isError ? 'error' : 'success'}`;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Show result
|
|
2195
|
+
const resultSection = document.getElementById(`${cardId}-result`);
|
|
2196
|
+
if (resultSection && data.result) {
|
|
2197
|
+
resultSection.style.display = '';
|
|
2198
|
+
resultSection.querySelector('pre').textContent = data.result;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
scrollChat();
|
|
2202
|
+
break;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
case 'compaction_start': {
|
|
2206
|
+
const cb = document.getElementById('compaction-banner');
|
|
2207
|
+
if (cb) {
|
|
2208
|
+
cb.className = 'compaction-banner active';
|
|
2209
|
+
cb.querySelector('.compaction-icon').innerHTML = '⚙';
|
|
2210
|
+
cb.querySelector('.compaction-text').textContent = 'Compacting conversation context...';
|
|
2211
|
+
}
|
|
2212
|
+
break;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
case 'compaction_end': {
|
|
2216
|
+
const cb = document.getElementById('compaction-banner');
|
|
2217
|
+
if (cb) {
|
|
2218
|
+
const icon = cb.querySelector('.compaction-icon');
|
|
2219
|
+
const text = cb.querySelector('.compaction-text');
|
|
2220
|
+
if (data.error) {
|
|
2221
|
+
icon.innerHTML = '⚠';
|
|
2222
|
+
cb.className = 'compaction-banner active compaction-error';
|
|
2223
|
+
text.textContent = `Compaction failed: ${esc(data.error)}`;
|
|
2224
|
+
} else {
|
|
2225
|
+
icon.innerHTML = '✓';
|
|
2226
|
+
cb.className = 'compaction-banner active compaction-done';
|
|
2227
|
+
const detail = data.tokensBefore && data.tokensAfter
|
|
2228
|
+
? ` (${data.tokensBefore.toLocaleString()} \u2192 ${data.tokensAfter.toLocaleString()} tokens)`
|
|
2229
|
+
: '';
|
|
2230
|
+
text.textContent = `Context compacted${detail}`;
|
|
2231
|
+
}
|
|
2232
|
+
// Auto-hide after 5s on success
|
|
2233
|
+
if (!data.error) {
|
|
2234
|
+
setTimeout(() => { cb.classList.remove('active'); }, 5000);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
break;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
case 'browser_vnc':
|
|
2241
|
+
if (data.port) {
|
|
2242
|
+
showVncPanel(buildVncUrl(data.port));
|
|
2243
|
+
} else if (data.url) {
|
|
2244
|
+
showVncPanel(data.url);
|
|
2245
|
+
} else {
|
|
2246
|
+
hideVncPanel();
|
|
2247
|
+
}
|
|
2248
|
+
break;
|
|
2249
|
+
|
|
2250
|
+
case 'done': {
|
|
2251
|
+
// Remove streaming cursor from all text segments
|
|
2252
|
+
if (flowEl) {
|
|
2253
|
+
flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
|
|
2254
|
+
}
|
|
2255
|
+
if (metaEl && (data.toolCalls > 0 || data.inputTokens > 0)) {
|
|
2256
|
+
metaEl.style.display = '';
|
|
2257
|
+
let metaHTML = '';
|
|
2258
|
+
if (data.toolCalls > 0) metaHTML += `<span>🔧 ${data.toolCalls} tool${data.toolCalls > 1 ? 's' : ''}</span>`;
|
|
2259
|
+
if (data.inputTokens > 0) metaHTML += `<span>→ ${data.inputTokens.toLocaleString()} in</span>`;
|
|
2260
|
+
if (data.outputTokens > 0) metaHTML += `<span>← ${data.outputTokens.toLocaleString()} out</span>`;
|
|
2261
|
+
if (data.compactionCount > 0) metaHTML += `<span>📦 ${data.compactionCount} compaction${data.compactionCount > 1 ? 's' : ''}</span>`;
|
|
2262
|
+
metaEl.innerHTML = metaHTML;
|
|
2263
|
+
}
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
case 'error': {
|
|
2268
|
+
const span = state.ensureTextSpan();
|
|
2269
|
+
span.classList.remove('streaming-cursor');
|
|
2270
|
+
const errorMsg = data.message || 'Unknown error';
|
|
2271
|
+
const currentText = segmentTexts.get(span) || '';
|
|
2272
|
+
span.innerHTML = (currentText ? formatMessage(currentText) + '<br><br>' : '') +
|
|
2273
|
+
`<span style="color:var(--red);">Error: ${esc(errorMsg)}</span>`;
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function toggleToolCard(cardId) {
|
|
2280
|
+
const card = document.getElementById(cardId);
|
|
2281
|
+
if (card) card.classList.toggle('expanded');
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function formatDurationShort(ms) {
|
|
2285
|
+
if (ms < 1000) return `${ms}ms`;
|
|
2286
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
2287
|
+
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function formatMessage(text) {
|
|
2291
|
+
if (!text) return '';
|
|
2292
|
+
// Simple markdown-ish formatting
|
|
2293
|
+
let html = esc(text);
|
|
2294
|
+
// Code blocks: ```...```
|
|
2295
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:10px;margin:8px 0;overflow-x:auto;font-family:var(--mono);font-size:12px;">$2</pre>');
|
|
2296
|
+
// Inline code: `...`
|
|
2297
|
+
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-primary);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:12px;">$1</code>');
|
|
2298
|
+
// Bold: **...**
|
|
2299
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
2300
|
+
// Newlines
|
|
2301
|
+
html = html.replace(/\n/g, '<br>');
|
|
2302
|
+
return html;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
function scrollChat() {
|
|
2306
|
+
const el = document.getElementById('chat-messages');
|
|
2307
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function chatMsgHTML(role, content) {
|
|
2311
|
+
const avatar = role === 'user' ? 'U' : 'C';
|
|
2312
|
+
const label = role === 'user' ? 'You' : 'CoStar';
|
|
2313
|
+
const formattedContent = role === 'user' ? esc(content) : formatMessage(content);
|
|
2314
|
+
return `<div class="chat-msg ${role}"><div class="avatar">${avatar}</div><div class="msg-content"><div class="msg-role">${label}</div><div>${formattedContent}</div></div></div>`;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// ─── Cron ────────────────────────────────────────
|
|
2318
|
+
async function loadCronJobs() {
|
|
2319
|
+
try {
|
|
2320
|
+
const jobs = await api('/api/cron/jobs');
|
|
2321
|
+
const tbody = document.getElementById('cron-table-body');
|
|
2322
|
+
|
|
2323
|
+
if (!Array.isArray(jobs) || jobs.length === 0) {
|
|
2324
|
+
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="empty-icon">⏰</div><p>No cron jobs configured yet.</p></div></td></tr>`;
|
|
2325
|
+
document.getElementById('cron-badge').textContent = '0';
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
document.getElementById('cron-badge').textContent = jobs.length;
|
|
2330
|
+
|
|
2331
|
+
tbody.innerHTML = jobs.map(job => {
|
|
2332
|
+
const schedule = job.cron_expression || (job.every ? `every ${job.every}` : job.at ? `at ${job.at}` : '--');
|
|
2333
|
+
const statusTag = job.status === 'active'
|
|
2334
|
+
? '<span class="tag tag-green">Active</span>'
|
|
2335
|
+
: '<span class="tag tag-muted">Paused</span>';
|
|
2336
|
+
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--';
|
|
2337
|
+
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '--';
|
|
2338
|
+
|
|
2339
|
+
return `<tr>
|
|
2340
|
+
<td><strong>${esc(job.name || job.id)}</strong><br><span style="font-size:11px;color:var(--text-muted);">${esc((job.task || '').substring(0, 80))}</span></td>
|
|
2341
|
+
<td><code style="font-size:12px;">${esc(schedule)}</code></td>
|
|
2342
|
+
<td>${statusTag}</td>
|
|
2343
|
+
<td style="font-size:12px;">${lastRun}</td>
|
|
2344
|
+
<td style="font-size:12px;">${nextRun}</td>
|
|
2345
|
+
<td>
|
|
2346
|
+
<button class="btn btn-sm" onclick="triggerCronJob('${job.id}')">Run</button>
|
|
2347
|
+
<button class="btn btn-sm btn-danger" onclick="deleteCronJob('${job.id}')">Delete</button>
|
|
2348
|
+
</td>
|
|
2349
|
+
</tr>`;
|
|
2350
|
+
}).join('');
|
|
2351
|
+
} catch (err) {
|
|
2352
|
+
document.getElementById('cron-table-body').innerHTML = `<tr><td colspan="6" style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</td></tr>`;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
async function loadCronStatus() {
|
|
2357
|
+
try {
|
|
2358
|
+
const status = await api('/api/cron/status');
|
|
2359
|
+
const el = document.getElementById('cron-status-info');
|
|
2360
|
+
el.innerHTML = [
|
|
2361
|
+
row('Running', status.isRunning ? 'Yes' : 'No'),
|
|
2362
|
+
row('Executing', status.isExecuting ? 'Yes' : 'No'),
|
|
2363
|
+
row('Last Check', status.lastCheckAt ? new Date(status.lastCheckAt).toLocaleString() : 'Never'),
|
|
2364
|
+
row('Jobs Executed', status.jobsExecuted),
|
|
2365
|
+
].join('');
|
|
2366
|
+
} catch (err) {
|
|
2367
|
+
document.getElementById('cron-status-info').innerHTML = `<div style="color:var(--red)">Error: ${esc(err.message)}</div>`;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
async function triggerCronJob(id) {
|
|
2372
|
+
try {
|
|
2373
|
+
await api(`/api/cron/jobs/${id}/run`, { method: 'POST' });
|
|
2374
|
+
loadCronJobs();
|
|
2375
|
+
} catch (err) {
|
|
2376
|
+
alert('Failed: ' + err.message);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
async function deleteCronJob(id) {
|
|
2381
|
+
if (!confirm('Delete this cron job?')) return;
|
|
2382
|
+
try {
|
|
2383
|
+
await api(`/api/cron/jobs/${id}`, { method: 'DELETE' });
|
|
2384
|
+
loadCronJobs();
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
alert('Failed: ' + err.message);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function openCronModal() {
|
|
2391
|
+
document.getElementById('cron-modal').classList.add('open');
|
|
2392
|
+
document.getElementById('cron-modal-title').textContent = 'New Cron Job';
|
|
2393
|
+
document.getElementById('cron-name').value = '';
|
|
2394
|
+
document.getElementById('cron-task').value = '';
|
|
2395
|
+
document.getElementById('cron-schedule-type').value = 'every';
|
|
2396
|
+
document.getElementById('cron-every').value = '';
|
|
2397
|
+
document.getElementById('cron-expression').value = '';
|
|
2398
|
+
document.getElementById('cron-at').value = '';
|
|
2399
|
+
toggleCronScheduleFields();
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function closeCronModal() {
|
|
2403
|
+
document.getElementById('cron-modal').classList.remove('open');
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function toggleCronScheduleFields() {
|
|
2407
|
+
const type = document.getElementById('cron-schedule-type').value;
|
|
2408
|
+
document.getElementById('cron-every-group').style.display = type === 'every' ? '' : 'none';
|
|
2409
|
+
document.getElementById('cron-expression-group').style.display = type === 'cron' ? '' : 'none';
|
|
2410
|
+
document.getElementById('cron-at-group').style.display = type === 'at' ? '' : 'none';
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
async function saveCronJob() {
|
|
2414
|
+
const name = document.getElementById('cron-name').value.trim();
|
|
2415
|
+
const task = document.getElementById('cron-task').value.trim();
|
|
2416
|
+
const type = document.getElementById('cron-schedule-type').value;
|
|
2417
|
+
|
|
2418
|
+
if (!name || !task) { alert('Name and task are required.'); return; }
|
|
2419
|
+
|
|
2420
|
+
const body = { name, task };
|
|
2421
|
+
|
|
2422
|
+
if (type === 'every') body.every = document.getElementById('cron-every').value.trim();
|
|
2423
|
+
else if (type === 'cron') body.cron_expression = document.getElementById('cron-expression').value.trim();
|
|
2424
|
+
else if (type === 'at') body.at = document.getElementById('cron-at').value.trim();
|
|
2425
|
+
|
|
2426
|
+
try {
|
|
2427
|
+
await api('/api/cron/jobs', { method: 'POST', body: JSON.stringify(body) });
|
|
2428
|
+
closeCronModal();
|
|
2429
|
+
loadCronJobs();
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
alert('Failed: ' + err.message);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// ─── Skills ──────────────────────────────────────
|
|
2436
|
+
async function loadSkills() {
|
|
2437
|
+
try {
|
|
2438
|
+
const data = await api('/api/skills');
|
|
2439
|
+
const grid = document.getElementById('skills-grid');
|
|
2440
|
+
|
|
2441
|
+
document.getElementById('skills-badge').textContent = data.eligible;
|
|
2442
|
+
|
|
2443
|
+
if (!data.skills || data.skills.length === 0) {
|
|
2444
|
+
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">⚡</div><p>No skills loaded. Add skills to your workspace or managed directory.</p></div>`;
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
grid.innerHTML = data.skills.map(skill => {
|
|
2449
|
+
const eligibleTag = skill.eligible
|
|
2450
|
+
? '<span class="tag tag-green">Eligible</span>'
|
|
2451
|
+
: '<span class="tag tag-muted">Inactive</span>';
|
|
2452
|
+
const sourceTag = `<span class="tag tag-blue">${esc(skill.source || 'unknown')}</span>`;
|
|
2453
|
+
|
|
2454
|
+
return `<div class="skill-card" onclick="showSkillDetail('${esc(skill.name)}')">
|
|
2455
|
+
<div class="skill-header">
|
|
2456
|
+
<span class="skill-emoji">${skill.emoji || '📦'}</span>
|
|
2457
|
+
<span class="skill-name">${esc(skill.name)}</span>
|
|
2458
|
+
</div>
|
|
2459
|
+
<div class="skill-desc">${esc(skill.description || 'No description')}</div>
|
|
2460
|
+
<div class="skill-footer">${eligibleTag} ${sourceTag}</div>
|
|
2461
|
+
</div>`;
|
|
2462
|
+
}).join('');
|
|
2463
|
+
} catch (err) {
|
|
2464
|
+
document.getElementById('skills-grid').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</div>`;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
async function showSkillDetail(name) {
|
|
2469
|
+
try {
|
|
2470
|
+
const skill = await api(`/api/skills/${encodeURIComponent(name)}`);
|
|
2471
|
+
document.getElementById('skill-modal-title').textContent = `${skill.emoji || ''} ${skill.name}`;
|
|
2472
|
+
|
|
2473
|
+
let html = `
|
|
2474
|
+
<div style="margin-bottom:16px;">
|
|
2475
|
+
<p style="color:var(--text-secondary);line-height:1.6;">${esc(skill.description || 'No description')}</p>
|
|
2476
|
+
</div>
|
|
2477
|
+
<div class="config-list" style="margin-bottom:16px;">
|
|
2478
|
+
${row('Source', skill.source)}
|
|
2479
|
+
${row('Eligible', skill.eligible ? 'Yes' : 'No')}
|
|
2480
|
+
${row('Base Directory', skill.baseDir)}
|
|
2481
|
+
${skill.homepage ? row('Homepage', skill.homepage) : ''}
|
|
2482
|
+
</div>
|
|
2483
|
+
`;
|
|
2484
|
+
|
|
2485
|
+
// Show file tabs if there are multiple files
|
|
2486
|
+
if (skill.files && skill.files.length > 0) {
|
|
2487
|
+
html += `<div style="margin-top:16px;">
|
|
2488
|
+
<label style="margin-bottom:8px;">Files</label>
|
|
2489
|
+
<div class="file-tabs" id="skill-file-tabs">`;
|
|
2490
|
+
|
|
2491
|
+
// Create tabs for each file
|
|
2492
|
+
skill.files.forEach((file, idx) => {
|
|
2493
|
+
const isActive = idx === 0 ? 'active' : '';
|
|
2494
|
+
html += `<button class="file-tab ${isActive}" onclick="switchSkillFileTab(${idx})">${esc(file.name)}</button>`;
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
html += `</div>`;
|
|
2498
|
+
|
|
2499
|
+
// Create content for each file
|
|
2500
|
+
html += `<div id="skill-file-contents">`;
|
|
2501
|
+
|
|
2502
|
+
skill.files.forEach((file, idx) => {
|
|
2503
|
+
const isActive = idx === 0 ? 'active' : '';
|
|
2504
|
+
const sizeKB = (file.size / 1024).toFixed(2);
|
|
2505
|
+
html += `
|
|
2506
|
+
<div class="file-content ${isActive}" id="skill-file-${idx}">
|
|
2507
|
+
<div style="margin-bottom:8px;font-size:11px;color:var(--text-muted);font-family:var(--mono);">
|
|
2508
|
+
${esc(file.path)} • ${sizeKB} KB
|
|
2509
|
+
</div>
|
|
2510
|
+
<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;max-height:400px;overflow-y:auto;">
|
|
2511
|
+
<pre style="font-family:var(--mono);font-size:12px;white-space:pre-wrap;color:var(--text-secondary);margin:0;">${esc(file.content)}</pre>
|
|
2512
|
+
</div>
|
|
2513
|
+
</div>
|
|
2514
|
+
`;
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
html += `</div></div>`;
|
|
2518
|
+
} else {
|
|
2519
|
+
// Fallback to showing just the main content if no files array
|
|
2520
|
+
if (skill.content) {
|
|
2521
|
+
html += `<div style="margin-top:16px;">
|
|
2522
|
+
<label>Content</label>
|
|
2523
|
+
<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;max-height:300px;overflow-y:auto;">
|
|
2524
|
+
<pre style="font-family:var(--mono);font-size:12px;white-space:pre-wrap;color:var(--text-secondary);">${esc(skill.content)}</pre>
|
|
2525
|
+
</div>
|
|
2526
|
+
</div>`;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
document.getElementById('skill-modal-content').innerHTML = html;
|
|
2531
|
+
document.getElementById('skill-modal').classList.add('open');
|
|
2532
|
+
} catch (err) {
|
|
2533
|
+
alert('Failed to load skill: ' + err.message);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function switchSkillFileTab(index) {
|
|
2538
|
+
// Update tab active states
|
|
2539
|
+
const tabs = document.querySelectorAll('.file-tab');
|
|
2540
|
+
tabs.forEach((tab, idx) => {
|
|
2541
|
+
tab.classList.toggle('active', idx === index);
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
// Update content active states
|
|
2545
|
+
const contents = document.querySelectorAll('.file-content');
|
|
2546
|
+
contents.forEach((content, idx) => {
|
|
2547
|
+
content.classList.toggle('active', idx === index);
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function closeSkillModal() {
|
|
2552
|
+
document.getElementById('skill-modal').classList.remove('open');
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// ─── Workspace ───────────────────────────────────
|
|
2556
|
+
async function loadWorkspaceFiles() {
|
|
2557
|
+
try {
|
|
2558
|
+
const data = await api('/api/workspace/files');
|
|
2559
|
+
const list = document.getElementById('workspace-file-list');
|
|
2560
|
+
|
|
2561
|
+
let html = '<div class="card-header"><h3>Files</h3></div>';
|
|
2562
|
+
|
|
2563
|
+
if (!data.files || data.files.length === 0) {
|
|
2564
|
+
html += '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No workspace files found.</div>';
|
|
2565
|
+
} else {
|
|
2566
|
+
html += data.files.map(f => {
|
|
2567
|
+
const sizeKB = (f.size / 1024).toFixed(1);
|
|
2568
|
+
return `<div class="file-item ${currentWorkspaceFile === f.name ? 'active' : ''}" onclick="openWorkspaceFile('${esc(f.name)}')">
|
|
2569
|
+
<span class="file-icon">📄</span>
|
|
2570
|
+
<span>${esc(f.name)}</span>
|
|
2571
|
+
<span class="file-meta">${sizeKB}KB</span>
|
|
2572
|
+
</div>`;
|
|
2573
|
+
}).join('');
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
list.innerHTML = html;
|
|
2577
|
+
} catch (err) {
|
|
2578
|
+
document.getElementById('workspace-file-list').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</div>`;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
async function openWorkspaceFile(name) {
|
|
2583
|
+
try {
|
|
2584
|
+
currentWorkspaceFile = name;
|
|
2585
|
+
const data = await api(`/api/workspace/files/${encodeURIComponent(name)}`);
|
|
2586
|
+
|
|
2587
|
+
document.getElementById('editor-filename').textContent = name;
|
|
2588
|
+
const editor = document.getElementById('workspace-editor-area');
|
|
2589
|
+
editor.value = data.content;
|
|
2590
|
+
editor.disabled = false;
|
|
2591
|
+
document.getElementById('save-file-btn').disabled = false;
|
|
2592
|
+
|
|
2593
|
+
// Update active state in list
|
|
2594
|
+
document.querySelectorAll('.file-item').forEach(el => {
|
|
2595
|
+
el.classList.toggle('active', el.textContent.includes(name));
|
|
2596
|
+
});
|
|
2597
|
+
} catch (err) {
|
|
2598
|
+
alert('Failed to open file: ' + err.message);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
async function saveWorkspaceFile() {
|
|
2603
|
+
if (!currentWorkspaceFile) return;
|
|
2604
|
+
|
|
2605
|
+
const content = document.getElementById('workspace-editor-area').value;
|
|
2606
|
+
|
|
2607
|
+
try {
|
|
2608
|
+
await api(`/api/workspace/files/${encodeURIComponent(currentWorkspaceFile)}`, {
|
|
2609
|
+
method: 'PUT',
|
|
2610
|
+
body: JSON.stringify({ content }),
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
const btn = document.getElementById('save-file-btn');
|
|
2614
|
+
btn.textContent = 'Saved!';
|
|
2615
|
+
setTimeout(() => { btn.textContent = 'Save'; }, 1500);
|
|
2616
|
+
} catch (err) {
|
|
2617
|
+
alert('Failed to save: ' + err.message);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// ─── Session Files ────────────────────────────────
|
|
2622
|
+
let currentSessionContent = '';
|
|
2623
|
+
let currentSessionFilename = null;
|
|
2624
|
+
let sessionRefreshInterval = null;
|
|
2625
|
+
let sessionViewMode = 'parsed'; // 'parsed' or 'raw'
|
|
2626
|
+
|
|
2627
|
+
function startSessionPolling() {
|
|
2628
|
+
stopSessionPolling();
|
|
2629
|
+
sessionRefreshInterval = setInterval(() => {
|
|
2630
|
+
if (currentPage !== 'sessions') { stopSessionPolling(); return; }
|
|
2631
|
+
if (currentSessionFilename) {
|
|
2632
|
+
refreshSessionViewer();
|
|
2633
|
+
} else {
|
|
2634
|
+
loadSessionFiles();
|
|
2635
|
+
}
|
|
2636
|
+
}, 3000);
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function stopSessionPolling() {
|
|
2640
|
+
if (sessionRefreshInterval) {
|
|
2641
|
+
clearInterval(sessionRefreshInterval);
|
|
2642
|
+
sessionRefreshInterval = null;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
async function refreshSessionViewer() {
|
|
2647
|
+
if (!currentSessionFilename) return;
|
|
2648
|
+
try {
|
|
2649
|
+
if (sessionViewMode === 'parsed') {
|
|
2650
|
+
const data = await api('/api/sessions/files/' + encodeURIComponent(currentSessionFilename) + '/parsed');
|
|
2651
|
+
const container = document.getElementById('sessions-parsed-view');
|
|
2652
|
+
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
|
|
2653
|
+
renderParsedSession(data);
|
|
2654
|
+
if (wasAtBottom) container.scrollTop = container.scrollHeight;
|
|
2655
|
+
document.getElementById('sessions-viewer-meta').textContent = `${data.totalTurns} turns \u00b7 ${data.totalEntries} entries`;
|
|
2656
|
+
} else {
|
|
2657
|
+
const res = await fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename));
|
|
2658
|
+
const raw = await res.text();
|
|
2659
|
+
if (raw === currentSessionContent) return;
|
|
2660
|
+
currentSessionContent = raw;
|
|
2661
|
+
const el = document.getElementById('sessions-viewer-content');
|
|
2662
|
+
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
|
2663
|
+
el.textContent = raw;
|
|
2664
|
+
if (wasAtBottom) el.scrollTop = el.scrollHeight;
|
|
2665
|
+
}
|
|
2666
|
+
} catch { /* ignore refresh errors */ }
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
async function loadSessionFiles() {
|
|
2670
|
+
try {
|
|
2671
|
+
const data = await api('/api/sessions/files');
|
|
2672
|
+
const files = data.files || [];
|
|
2673
|
+
const tbody = document.getElementById('sessions-table-body');
|
|
2674
|
+
|
|
2675
|
+
const dirEl = document.getElementById('sessions-dir-path');
|
|
2676
|
+
if (data.directory) dirEl.textContent = data.directory;
|
|
2677
|
+
|
|
2678
|
+
const badge = document.getElementById('sessions-badge');
|
|
2679
|
+
if (badge) {
|
|
2680
|
+
badge.textContent = files.length;
|
|
2681
|
+
badge.style.display = files.length > 0 ? '' : 'none';
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
if (files.length === 0) {
|
|
2685
|
+
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state"><div class="empty-icon">📋</div><p>No session files found.</p></div></td></tr>';
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
tbody.innerHTML = files.map(f => {
|
|
2690
|
+
const modified = new Date(f.modifiedAt);
|
|
2691
|
+
const timeStr = modified.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2692
|
+
const dateStr = modified.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
2693
|
+
const sizeStr = f.sizeKB > 1024 ? (f.sizeKB / 1024).toFixed(1) + ' MB' : f.sizeKB + ' KB';
|
|
2694
|
+
const shortName = f.filename.length > 50 ? f.filename.slice(0, 24) + '...' + f.filename.slice(-20) : f.filename;
|
|
2695
|
+
return `<tr style="cursor:pointer;" onclick="openSessionFile('${escHtml(f.filename)}')">
|
|
2696
|
+
<td><code style="font-size:12px;color:var(--accent);" title="${escHtml(f.filename)}">${escHtml(shortName)}</code></td>
|
|
2697
|
+
<td style="font-family:var(--mono);font-size:12px;white-space:nowrap;">${sizeStr}</td>
|
|
2698
|
+
<td style="font-family:var(--mono);font-size:12px;white-space:nowrap;" title="${modified.toISOString()}">${dateStr} ${timeStr}</td>
|
|
2699
|
+
<td><button class="btn btn-sm" onclick="event.stopPropagation();openSessionFile('${escHtml(f.filename)}')" style="font-size:11px;padding:2px 8px;">View</button></td>
|
|
2700
|
+
</tr>`;
|
|
2701
|
+
}).join('');
|
|
2702
|
+
} catch (err) {
|
|
2703
|
+
document.getElementById('sessions-table-body').innerHTML = `<tr><td colspan="4" style="color:var(--red);padding:16px;">Error: ${escHtml(err.message)}</td></tr>`;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
async function openSessionFile(filename) {
|
|
2708
|
+
currentSessionFilename = filename;
|
|
2709
|
+
sessionViewMode = 'parsed';
|
|
2710
|
+
document.getElementById('sessions-list-view').style.display = 'none';
|
|
2711
|
+
document.getElementById('sessions-viewer').style.display = '';
|
|
2712
|
+
document.getElementById('sessions-viewer-title').textContent = filename;
|
|
2713
|
+
document.getElementById('sessions-viewer-meta').textContent = 'Loading...';
|
|
2714
|
+
updateSessionViewTabs();
|
|
2715
|
+
|
|
2716
|
+
// Load parsed view by default
|
|
2717
|
+
try {
|
|
2718
|
+
const data = await api('/api/sessions/files/' + encodeURIComponent(filename) + '/parsed');
|
|
2719
|
+
const ctxInfo = data.hasCompaction ? ` \u00b7 ${data.turnsInContext} in context \u00b7 ${data.turnsCompactedAway} compacted` : '';
|
|
2720
|
+
document.getElementById('sessions-viewer-meta').textContent = `${data.totalTurns} turns \u00b7 ${data.totalEntries} entries${ctxInfo}`;
|
|
2721
|
+
renderParsedSession(data);
|
|
2722
|
+
// Scroll to bottom
|
|
2723
|
+
const container = document.getElementById('sessions-parsed-view');
|
|
2724
|
+
container.scrollTop = container.scrollHeight;
|
|
2725
|
+
} catch (err) {
|
|
2726
|
+
document.getElementById('sessions-parsed-content').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${escHtml(err.message)}</div>`;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function switchSessionView(mode) {
|
|
2731
|
+
sessionViewMode = mode;
|
|
2732
|
+
updateSessionViewTabs();
|
|
2733
|
+
if (mode === 'raw' && currentSessionFilename) {
|
|
2734
|
+
loadRawSessionView();
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function updateSessionViewTabs() {
|
|
2739
|
+
document.getElementById('sessions-tab-parsed').style.fontWeight = sessionViewMode === 'parsed' ? '600' : '400';
|
|
2740
|
+
document.getElementById('sessions-tab-raw').style.fontWeight = sessionViewMode === 'raw' ? '600' : '400';
|
|
2741
|
+
document.getElementById('sessions-parsed-view').style.display = sessionViewMode === 'parsed' ? '' : 'none';
|
|
2742
|
+
document.getElementById('sessions-raw-view').style.display = sessionViewMode === 'raw' ? '' : 'none';
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
async function loadRawSessionView() {
|
|
2746
|
+
try {
|
|
2747
|
+
const el = document.getElementById('sessions-viewer-content');
|
|
2748
|
+
el.textContent = 'Loading...';
|
|
2749
|
+
const res = await fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename));
|
|
2750
|
+
const raw = await res.text();
|
|
2751
|
+
currentSessionContent = raw;
|
|
2752
|
+
// Show raw JSONL lines, each pretty-printed
|
|
2753
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
2754
|
+
el.textContent = lines.map(line => {
|
|
2755
|
+
try { return JSON.stringify(JSON.parse(line), null, 2); } catch { return line; }
|
|
2756
|
+
}).join('\n---\n');
|
|
2757
|
+
el.scrollTop = el.scrollHeight;
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
document.getElementById('sessions-viewer-content').textContent = 'Error: ' + err.message;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function renderParsedSession(data) {
|
|
2764
|
+
const el = document.getElementById('sessions-parsed-content');
|
|
2765
|
+
let html = '';
|
|
2766
|
+
|
|
2767
|
+
// Session header — single line
|
|
2768
|
+
if (data.session) {
|
|
2769
|
+
html += `<div style="padding:3px 8px;font-size:10px;color:var(--text-muted);font-family:var(--mono);border-bottom:1px solid var(--border);">
|
|
2770
|
+
${escHtml(data.session.id || '').slice(0,8)}.. | v${data.session.version || '?'} | ${escHtml(data.session.cwd || '')} | ${new Date(data.session.timestamp || '').toLocaleString()}
|
|
2771
|
+
</div>`;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
let shownContextBoundary = false;
|
|
2775
|
+
let lastWasOutOfContext = false;
|
|
2776
|
+
|
|
2777
|
+
const turns = data.turns || [];
|
|
2778
|
+
for (let i = 0; i < turns.length; i++) {
|
|
2779
|
+
const turn = turns[i];
|
|
2780
|
+
const time = new Date(turn.timestamp);
|
|
2781
|
+
const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2782
|
+
const dateStr = time.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
2783
|
+
|
|
2784
|
+
// Context boundary
|
|
2785
|
+
if (!turn.inContext && data.hasCompaction) lastWasOutOfContext = true;
|
|
2786
|
+
if (turn.inContext && lastWasOutOfContext && !shownContextBoundary) {
|
|
2787
|
+
shownContextBoundary = true;
|
|
2788
|
+
if (data.compactions && data.compactions.length > 0) {
|
|
2789
|
+
const c = data.compactions[data.compactions.length - 1];
|
|
2790
|
+
html += `<div style="padding:4px 8px;background:#a855f712;border-left:2px solid #a855f7;margin:2px 0;font-size:10px;">
|
|
2791
|
+
<span style="color:#a855f7;font-weight:600;">COMPACTION</span>
|
|
2792
|
+
<span style="color:var(--text-muted);"> ${new Date(c.timestamp).toLocaleString()}${c.tokensBefore ? ' \u2014 ' + c.tokensBefore.toLocaleString() + ' tok' : ''}</span>
|
|
2793
|
+
<div style="color:var(--text-secondary);margin-top:2px;line-height:1.3;white-space:pre-wrap;max-height:80px;overflow:hidden;">${escHtml(c.summary || '').slice(0, 500)}${(c.summary || '').length > 500 ? '...' : ''}</div>
|
|
2794
|
+
</div>`;
|
|
2795
|
+
}
|
|
2796
|
+
html += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">
|
|
2797
|
+
<div style="flex:1;height:1px;background:#10b98140;"></div>
|
|
2798
|
+
<span style="font-size:9px;color:#10b981;font-weight:700;letter-spacing:0.5px;">ACTIVE CONTEXT</span>
|
|
2799
|
+
<div style="flex:1;height:1px;background:#10b98140;"></div>
|
|
2800
|
+
</div>`;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
const dim = !turn.inContext && data.hasCompaction ? 'opacity:0.35;' : '';
|
|
2804
|
+
const hbBadge = turn.isHeartbeat ? '<span style="color:#f59e0b;font-size:8px;font-weight:700;margin-left:3px;">HB</span>' : '';
|
|
2805
|
+
const compBadge = !turn.inContext && data.hasCompaction ? '<span style="color:#ef4444;font-size:8px;margin-left:3px;">OLD</span>' : '';
|
|
2806
|
+
|
|
2807
|
+
// Native server tool calls summary
|
|
2808
|
+
const toolStr = turn.toolCalls && turn.toolCalls.length > 0
|
|
2809
|
+
? turn.toolCalls.map(tc => tc.name).join(', ')
|
|
2810
|
+
: '';
|
|
2811
|
+
|
|
2812
|
+
// Tokens / cost
|
|
2813
|
+
const metaRight = [];
|
|
2814
|
+
if (turn.model) metaRight.push(escHtml(turn.model).replace('claude-', '').replace('-20250514',''));
|
|
2815
|
+
if (turn.usage) metaRight.push((turn.usage.totalTokens || 0).toLocaleString() + 't');
|
|
2816
|
+
if (turn.usage?.cost) metaRight.push('$' + turn.usage.cost.toFixed(3));
|
|
2817
|
+
|
|
2818
|
+
// Single compact turn block
|
|
2819
|
+
html += `<div style="border-left:2px solid var(--accent);padding:3px 8px;${dim}">`;
|
|
2820
|
+
|
|
2821
|
+
// Header line: #N time USER message-preview
|
|
2822
|
+
const userPreview = escHtml(turn.userMessage).replace(/\n/g, ' ').slice(0, 120);
|
|
2823
|
+
html += `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:4px;">
|
|
2824
|
+
<div style="font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
2825
|
+
<span style="color:var(--text-muted);font-family:var(--mono);">#${turn.turnNumber}</span>
|
|
2826
|
+
<span style="color:var(--text-muted);font-family:var(--mono);margin:0 2px;">${dateStr} ${timeStr}</span>${hbBadge}${compBadge}
|
|
2827
|
+
<span style="color:var(--accent);font-weight:600;margin-left:3px;">USER</span>
|
|
2828
|
+
<span style="color:var(--text-primary);margin-left:4px;">${userPreview}</span>
|
|
2829
|
+
</div>
|
|
2830
|
+
<div style="font-size:9px;color:var(--text-muted);white-space:nowrap;flex-shrink:0;">${metaRight.join(' \u00b7 ')}</div>
|
|
2831
|
+
</div>`;
|
|
2832
|
+
|
|
2833
|
+
// Native server tool calls — collapsed single line
|
|
2834
|
+
if (toolStr) {
|
|
2835
|
+
html += `<div style="font-size:9px;color:#3b82f6;font-family:var(--mono);padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
2836
|
+
\u2514 ${turn.toolCalls.length} tool${turn.toolCalls.length > 1 ? 's' : ''}: ${escHtml(toolStr).slice(0, 100)}
|
|
2837
|
+
</div>`;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// Assistant messages — render each entry separately
|
|
2841
|
+
if (turn.assistantMessages && turn.assistantMessages.length > 0) {
|
|
2842
|
+
for (const aMsg of turn.assistantMessages) {
|
|
2843
|
+
const trimmed = aMsg.trim();
|
|
2844
|
+
if (!trimmed) continue;
|
|
2845
|
+
// Detect [label → result] tool entries from client session sync
|
|
2846
|
+
const toolMatch = trimmed.match(/^\[(.+?)\s*→\s*([\s\S]*)\]$/);
|
|
2847
|
+
if (toolMatch) {
|
|
2848
|
+
const tLabel = escHtml(toolMatch[1]);
|
|
2849
|
+
const tResult = escHtml(toolMatch[2]).replace(/\n/g, ' ').slice(0, 150);
|
|
2850
|
+
html += `<div style="font-size:9px;color:#3b82f6;font-family:var(--mono);padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
2851
|
+
\u2514 <span style="font-weight:600;">${tLabel}</span> <span style="color:var(--text-muted);">→ ${tResult}${toolMatch[2].length > 150 ? '...' : ''}</span>
|
|
2852
|
+
</div>`;
|
|
2853
|
+
} else {
|
|
2854
|
+
// Regular assistant text
|
|
2855
|
+
const aPreview = escHtml(trimmed).replace(/\n/g, ' ').slice(0, 200);
|
|
2856
|
+
html += `<div style="font-size:10px;padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
2857
|
+
<span style="color:#10b981;font-weight:600;">ASST</span>
|
|
2858
|
+
<span style="color:var(--text-secondary);margin-left:4px;">${aPreview}${trimmed.length > 200 ? '...' : ''}</span>
|
|
2859
|
+
</div>`;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
html += `</div>`;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
if (turns.length === 0) {
|
|
2868
|
+
html += '<div style="text-align:center;color:var(--text-muted);padding:20px;font-size:12px;">No conversation turns found.</div>';
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
el.innerHTML = html;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
function closeSessionViewer() {
|
|
2875
|
+
document.getElementById('sessions-viewer').style.display = 'none';
|
|
2876
|
+
document.getElementById('sessions-list-view').style.display = '';
|
|
2877
|
+
currentSessionContent = '';
|
|
2878
|
+
currentSessionFilename = null;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function copySessionContent() {
|
|
2882
|
+
if (!currentSessionFilename) return;
|
|
2883
|
+
fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename))
|
|
2884
|
+
.then(r => r.text())
|
|
2885
|
+
.then(raw => {
|
|
2886
|
+
navigator.clipboard.writeText(raw).then(() => {
|
|
2887
|
+
const btn = event.target;
|
|
2888
|
+
const orig = btn.textContent;
|
|
2889
|
+
btn.textContent = 'Copied!';
|
|
2890
|
+
setTimeout(() => btn.textContent = orig, 1500);
|
|
2891
|
+
});
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
function escHtml(str) {
|
|
2896
|
+
if (!str) return '';
|
|
2897
|
+
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// ─── Logs ────────────────────────────────────────
|
|
2901
|
+
async function loadLogs() {
|
|
2902
|
+
try {
|
|
2903
|
+
const component = document.getElementById('log-component-filter').value;
|
|
2904
|
+
const level = document.getElementById('log-level-filter').value;
|
|
2905
|
+
const params = new URLSearchParams({ limit: '200' });
|
|
2906
|
+
if (component) params.set('component', component);
|
|
2907
|
+
if (level) params.set('level', level);
|
|
2908
|
+
|
|
2909
|
+
const data = await api(`/api/logs?${params}`);
|
|
2910
|
+
|
|
2911
|
+
// Update component filter options
|
|
2912
|
+
const compFilter = document.getElementById('log-component-filter');
|
|
2913
|
+
const currentComp = compFilter.value;
|
|
2914
|
+
if (data.components && data.components.length > 0) {
|
|
2915
|
+
const opts = ['<option value="">All Components</option>'];
|
|
2916
|
+
for (const c of data.components) {
|
|
2917
|
+
opts.push(`<option value="${esc(c)}" ${c === currentComp ? 'selected' : ''}>${esc(c)}</option>`);
|
|
2918
|
+
}
|
|
2919
|
+
compFilter.innerHTML = opts.join('');
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
renderLogEntries(data.entries || []);
|
|
2923
|
+
} catch (err) {
|
|
2924
|
+
document.getElementById('log-entries').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
function renderLogEntries(entries) {
|
|
2929
|
+
const el = document.getElementById('log-entries');
|
|
2930
|
+
|
|
2931
|
+
if (entries.length === 0) {
|
|
2932
|
+
el.innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">No log entries.</div>';
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
el.innerHTML = entries.map(e => {
|
|
2937
|
+
const time = new Date(e.timestamp).toLocaleTimeString();
|
|
2938
|
+
return `<div class="log-line">
|
|
2939
|
+
<span class="log-time">${time}</span>
|
|
2940
|
+
<span class="log-level ${e.level}">${e.level}</span>
|
|
2941
|
+
<span class="log-comp">${esc(e.component)}</span>
|
|
2942
|
+
<span class="log-msg">${esc(e.message)}</span>
|
|
2943
|
+
</div>`;
|
|
2944
|
+
}).join('');
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
function startLogStream() {
|
|
2948
|
+
if (logEventSource) return;
|
|
2949
|
+
const toggle = document.getElementById('log-stream-toggle');
|
|
2950
|
+
if (!toggle.checked) return;
|
|
2951
|
+
|
|
2952
|
+
logEventSource = new EventSource('/api/logs/stream');
|
|
2953
|
+
|
|
2954
|
+
logEventSource.onmessage = (event) => {
|
|
2955
|
+
try {
|
|
2956
|
+
const entry = JSON.parse(event.data);
|
|
2957
|
+
if (entry.type === 'connected') return;
|
|
2958
|
+
|
|
2959
|
+
// Apply filters
|
|
2960
|
+
const compFilter = document.getElementById('log-component-filter').value;
|
|
2961
|
+
const levelFilter = document.getElementById('log-level-filter').value;
|
|
2962
|
+
if (compFilter && entry.component !== compFilter) return;
|
|
2963
|
+
if (levelFilter && entry.level !== levelFilter) return;
|
|
2964
|
+
|
|
2965
|
+
const el = document.getElementById('log-entries');
|
|
2966
|
+
const emptyMsg = el.querySelector('div[style*="text-align"]');
|
|
2967
|
+
if (emptyMsg) emptyMsg.remove();
|
|
2968
|
+
|
|
2969
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
2970
|
+
const html = `<div class="log-line">
|
|
2971
|
+
<span class="log-time">${time}</span>
|
|
2972
|
+
<span class="log-level ${entry.level}">${entry.level}</span>
|
|
2973
|
+
<span class="log-comp">${esc(entry.component)}</span>
|
|
2974
|
+
<span class="log-msg">${esc(entry.message)}</span>
|
|
2975
|
+
</div>`;
|
|
2976
|
+
|
|
2977
|
+
// Prepend (newest first)
|
|
2978
|
+
el.insertAdjacentHTML('afterbegin', html);
|
|
2979
|
+
|
|
2980
|
+
// Limit DOM entries
|
|
2981
|
+
const lines = el.querySelectorAll('.log-line');
|
|
2982
|
+
if (lines.length > 500) {
|
|
2983
|
+
for (let i = 500; i < lines.length; i++) lines[i].remove();
|
|
2984
|
+
}
|
|
2985
|
+
} catch { /* ignore */ }
|
|
2986
|
+
};
|
|
2987
|
+
|
|
2988
|
+
logEventSource.onerror = () => {
|
|
2989
|
+
// Will auto-reconnect
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function stopLogStream() {
|
|
2994
|
+
if (logEventSource) {
|
|
2995
|
+
logEventSource.close();
|
|
2996
|
+
logEventSource = null;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function toggleLogStream() {
|
|
3001
|
+
const toggle = document.getElementById('log-stream-toggle');
|
|
3002
|
+
if (toggle.checked) {
|
|
3003
|
+
startLogStream();
|
|
3004
|
+
} else {
|
|
3005
|
+
stopLogStream();
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
function clearLogView() {
|
|
3010
|
+
document.getElementById('log-entries').innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">Log view cleared.</div>';
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// ─── Settings ────────────────────────────────────
|
|
3014
|
+
async function loadSettings() {
|
|
3015
|
+
try {
|
|
3016
|
+
const data = await api('/api/config');
|
|
3017
|
+
|
|
3018
|
+
const configEl = document.getElementById('settings-config');
|
|
3019
|
+
const entries = Object.entries(data.config || {});
|
|
3020
|
+
|
|
3021
|
+
if (entries.length === 0) {
|
|
3022
|
+
configEl.innerHTML = '<div style="padding:20px;color:var(--text-muted);">No configuration found. Run <code>costar setup</code> to configure.</div>';
|
|
3023
|
+
} else {
|
|
3024
|
+
configEl.innerHTML = entries.map(([k, v]) => row(k, v)).join('');
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
const pathsEl = document.getElementById('settings-paths');
|
|
3028
|
+
pathsEl.innerHTML = [
|
|
3029
|
+
row('Config Directory', data.configDir),
|
|
3030
|
+
row('Logs Directory', data.logsDir),
|
|
3031
|
+
].join('');
|
|
3032
|
+
} catch (err) {
|
|
3033
|
+
document.getElementById('settings-config').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// Also load env variables
|
|
3037
|
+
loadEnvVariables();
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// ─── Environment Variables ───────────────────────
|
|
3041
|
+
let envVariables = [];
|
|
3042
|
+
|
|
3043
|
+
async function loadEnvVariables() {
|
|
3044
|
+
try {
|
|
3045
|
+
const data = await api('/api/env');
|
|
3046
|
+
envVariables = data.variables || [];
|
|
3047
|
+
const tbody = document.getElementById('env-table-body');
|
|
3048
|
+
|
|
3049
|
+
if (envVariables.length === 0) {
|
|
3050
|
+
tbody.innerHTML = `<tr><td colspan="3"><div class="empty-state"><div class="empty-icon">🔧</div><p>No environment variables configured.</p></div></td></tr>`;
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
const platformVars = envVariables.filter(e => e.source === 'platform');
|
|
3055
|
+
const userVars = envVariables.filter(e => e.source !== 'platform');
|
|
3056
|
+
|
|
3057
|
+
let html = '';
|
|
3058
|
+
|
|
3059
|
+
// Platform-managed keys section (compact, read-only)
|
|
3060
|
+
if (platformVars.length > 0) {
|
|
3061
|
+
html += `<tr><td colspan="3" style="padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border);">
|
|
3062
|
+
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Platform Managed</span>
|
|
3063
|
+
<span style="font-size:10px;color:var(--text-muted);margin-left:6px;">(read-only)</span>
|
|
3064
|
+
</td></tr>`;
|
|
3065
|
+
html += platformVars.map((env, idx) => {
|
|
3066
|
+
const realIdx = envVariables.indexOf(env);
|
|
3067
|
+
const valueDisplay = env.masked
|
|
3068
|
+
? `<span style="font-family:var(--mono);font-size:12px;color:var(--text-muted);" id="env-value-${realIdx}">${esc(env.value)}</span>
|
|
3069
|
+
<button class="btn btn-sm" onclick="revealEnvValue('${esc(env.key)}', ${realIdx})" id="env-reveal-${realIdx}" style="margin-left:4px;font-size:11px;padding:2px 6px;">Reveal</button>`
|
|
3070
|
+
: `<span style="font-family:var(--mono);font-size:12px;">${esc(env.value)}</span>`;
|
|
3071
|
+
return `<tr style="opacity:0.85;">
|
|
3072
|
+
<td style="padding:4px 12px;"><code style="font-size:12px;color:var(--accent);">${esc(env.key)}</code></td>
|
|
3073
|
+
<td style="padding:4px 12px;">${valueDisplay}</td>
|
|
3074
|
+
<td style="padding:4px 12px;"><span style="font-size:10px;color:var(--text-muted);background:var(--bg);padding:2px 6px;border-radius:4px;">managed</span></td>
|
|
3075
|
+
</tr>`;
|
|
3076
|
+
}).join('');
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// User-editable keys section
|
|
3080
|
+
if (userVars.length > 0) {
|
|
3081
|
+
if (platformVars.length > 0) {
|
|
3082
|
+
html += `<tr><td colspan="3" style="padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border);">
|
|
3083
|
+
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">User Configured</span>
|
|
3084
|
+
</td></tr>`;
|
|
3085
|
+
}
|
|
3086
|
+
html += userVars.map((env, idx) => {
|
|
3087
|
+
const realIdx = envVariables.indexOf(env);
|
|
3088
|
+
const valueDisplay = env.masked
|
|
3089
|
+
? `<span style="font-family:var(--mono);font-size:12px;color:var(--text-muted);" id="env-value-${realIdx}">${esc(env.value)}</span>
|
|
3090
|
+
<button class="btn btn-sm" onclick="revealEnvValue('${esc(env.key)}', ${realIdx})" id="env-reveal-${realIdx}" style="margin-left:4px;font-size:11px;padding:2px 6px;">Reveal</button>`
|
|
3091
|
+
: `<span style="font-family:var(--mono);font-size:12px;">${esc(env.value)}</span>`;
|
|
3092
|
+
return `<tr>
|
|
3093
|
+
<td style="padding:4px 12px;"><code style="font-size:12px;color:var(--accent);">${esc(env.key)}</code></td>
|
|
3094
|
+
<td style="padding:4px 12px;">${valueDisplay}</td>
|
|
3095
|
+
<td style="padding:4px 12px;">
|
|
3096
|
+
<button class="btn btn-sm" onclick="editEnvVariable('${esc(env.key)}')" style="font-size:11px;padding:2px 6px;">Edit</button>
|
|
3097
|
+
<button class="btn btn-sm btn-danger" onclick="deleteEnvVariable('${esc(env.key)}')" style="font-size:11px;padding:2px 6px;">Delete</button>
|
|
3098
|
+
</td>
|
|
3099
|
+
</tr>`;
|
|
3100
|
+
}).join('');
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
tbody.innerHTML = html;
|
|
3104
|
+
} catch (err) {
|
|
3105
|
+
document.getElementById('env-table-body').innerHTML = `<tr><td colspan="3" style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</td></tr>`;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
async function revealEnvValue(key, idx) {
|
|
3110
|
+
try {
|
|
3111
|
+
const btn = document.getElementById(`env-reveal-${idx}`);
|
|
3112
|
+
const valueEl = document.getElementById(`env-value-${idx}`);
|
|
3113
|
+
|
|
3114
|
+
if (!btn || !valueEl) return;
|
|
3115
|
+
|
|
3116
|
+
// If already revealed, hide it again
|
|
3117
|
+
if (btn.textContent.includes('Hide')) {
|
|
3118
|
+
const maskedVar = envVariables[idx];
|
|
3119
|
+
valueEl.textContent = maskedVar.value;
|
|
3120
|
+
btn.innerHTML = '👁 Reveal';
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
btn.disabled = true;
|
|
3125
|
+
btn.textContent = 'Loading...';
|
|
3126
|
+
|
|
3127
|
+
const data = await api(`/api/env/${encodeURIComponent(key)}`);
|
|
3128
|
+
valueEl.textContent = data.value;
|
|
3129
|
+
btn.disabled = false;
|
|
3130
|
+
btn.innerHTML = '👁 Hide';
|
|
3131
|
+
|
|
3132
|
+
// Auto-hide after 10 seconds
|
|
3133
|
+
setTimeout(() => {
|
|
3134
|
+
if (btn.textContent.includes('Hide')) {
|
|
3135
|
+
const maskedVar = envVariables[idx];
|
|
3136
|
+
valueEl.textContent = maskedVar.value;
|
|
3137
|
+
btn.innerHTML = '👁 Reveal';
|
|
3138
|
+
}
|
|
3139
|
+
}, 10000);
|
|
3140
|
+
} catch (err) {
|
|
3141
|
+
alert('Failed to reveal value: ' + err.message);
|
|
3142
|
+
const btn = document.getElementById(`env-reveal-${idx}`);
|
|
3143
|
+
if (btn) {
|
|
3144
|
+
btn.disabled = false;
|
|
3145
|
+
btn.innerHTML = '👁 Reveal';
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function openEnvModal() {
|
|
3151
|
+
document.getElementById('env-modal').classList.add('open');
|
|
3152
|
+
document.getElementById('env-modal-title').textContent = 'Add Environment Variable';
|
|
3153
|
+
document.getElementById('env-key').value = '';
|
|
3154
|
+
document.getElementById('env-key').disabled = false;
|
|
3155
|
+
document.getElementById('env-value').value = '';
|
|
3156
|
+
document.getElementById('env-key').dataset.editMode = 'false';
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
function closeEnvModal() {
|
|
3160
|
+
document.getElementById('env-modal').classList.remove('open');
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function editEnvVariable(key) {
|
|
3164
|
+
const envVar = envVariables.find(e => e.key === key);
|
|
3165
|
+
if (!envVar) return;
|
|
3166
|
+
|
|
3167
|
+
document.getElementById('env-modal').classList.add('open');
|
|
3168
|
+
document.getElementById('env-modal-title').textContent = 'Edit Environment Variable';
|
|
3169
|
+
document.getElementById('env-key').value = key;
|
|
3170
|
+
document.getElementById('env-key').disabled = true; // Can't change key when editing
|
|
3171
|
+
document.getElementById('env-value').value = ''; // Don't pre-fill for security
|
|
3172
|
+
document.getElementById('env-value').placeholder = 'Enter new value...';
|
|
3173
|
+
document.getElementById('env-key').dataset.editMode = 'true';
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
async function saveEnvVariable() {
|
|
3177
|
+
const keyInput = document.getElementById('env-key');
|
|
3178
|
+
const valueInput = document.getElementById('env-value');
|
|
3179
|
+
const key = keyInput.value.trim().toUpperCase();
|
|
3180
|
+
const value = valueInput.value.trim();
|
|
3181
|
+
const isEdit = keyInput.dataset.editMode === 'true';
|
|
3182
|
+
|
|
3183
|
+
if (!key) {
|
|
3184
|
+
alert('Key is required.');
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
if (!value) {
|
|
3189
|
+
alert('Value is required.');
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// Validate key format
|
|
3194
|
+
if (!/^[A-Z0-9_]+$/.test(key)) {
|
|
3195
|
+
alert('Key must contain only uppercase letters, numbers, and underscores.');
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
try {
|
|
3200
|
+
if (isEdit) {
|
|
3201
|
+
await api(`/api/env/${encodeURIComponent(key)}`, {
|
|
3202
|
+
method: 'PUT',
|
|
3203
|
+
body: JSON.stringify({ value }),
|
|
3204
|
+
});
|
|
3205
|
+
} else {
|
|
3206
|
+
await api('/api/env', {
|
|
3207
|
+
method: 'POST',
|
|
3208
|
+
body: JSON.stringify({ key, value }),
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
closeEnvModal();
|
|
3213
|
+
loadEnvVariables();
|
|
3214
|
+
} catch (err) {
|
|
3215
|
+
alert('Failed to save: ' + err.message);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
async function deleteEnvVariable(key) {
|
|
3220
|
+
if (!confirm(`Delete environment variable "${key}"?\n\nThis will remove it from ~/.costar/.env and the current process.`)) {
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
try {
|
|
3225
|
+
await api(`/api/env/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
|
3226
|
+
loadEnvVariables();
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
alert('Failed to delete: ' + err.message);
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
// ─── Utilities ───────────────────────────────────
|
|
3233
|
+
function esc(str) {
|
|
3234
|
+
if (!str) return '';
|
|
3235
|
+
const div = document.createElement('div');
|
|
3236
|
+
div.textContent = String(str);
|
|
3237
|
+
return div.innerHTML;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// ─── Init ────────────────────────────────────────
|
|
3241
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3242
|
+
loadDashboard();
|
|
3243
|
+
});
|
|
3244
|
+
|
|
3245
|
+
// Close modals on overlay click
|
|
3246
|
+
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
3247
|
+
overlay.addEventListener('click', (e) => {
|
|
3248
|
+
if (e.target === overlay) overlay.classList.remove('open');
|
|
3249
|
+
});
|
|
3250
|
+
});
|
|
3251
|
+
|
|
3252
|
+
// Keyboard shortcut: Escape to close modals
|
|
3253
|
+
document.addEventListener('keydown', (e) => {
|
|
3254
|
+
if (e.key === 'Escape') {
|
|
3255
|
+
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
|
|
3256
|
+
}
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
</script>
|
|
3260
|
+
</body>
|
|
3261
|
+
</html>
|