uv-suite 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/watchtower/dashboard.html +359 -83
- package/watchtower/server.js +76 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uv-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
|
|
5
5
|
"author": "Utsav Anand",
|
|
6
6
|
"license": "MIT",
|
|
@@ -5,95 +5,340 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>UV Suite Watchtower</title>
|
|
7
7
|
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--bg: #0b0b0d;
|
|
11
|
+
--surface: #16161a;
|
|
12
|
+
--surface-hover: #1d1d22;
|
|
13
|
+
--border: #26262c;
|
|
14
|
+
--border-subtle: #1a1a1e;
|
|
15
|
+
--text: #e9e9ec;
|
|
16
|
+
--text-muted: #9a9aa3;
|
|
17
|
+
--text-dim: #6a6a73;
|
|
18
|
+
--accent: #0a84ff;
|
|
19
|
+
--accent-contrast: #ffffff;
|
|
20
|
+
--success: #30d158;
|
|
21
|
+
--success-soft: rgba(48, 209, 88, 0.18);
|
|
22
|
+
--danger: #ff453a;
|
|
23
|
+
--danger-soft: #ff375f;
|
|
24
|
+
--warning: #ff9f0a;
|
|
25
|
+
--yellow: #ffd60a;
|
|
26
|
+
--info: #64d2ff;
|
|
27
|
+
--purple: #bf5af2;
|
|
28
|
+
--purple-soft: #ac8ee0;
|
|
29
|
+
--peach: #ff6961;
|
|
30
|
+
|
|
31
|
+
--event-latest-bg: #17171c;
|
|
32
|
+
--needs-human-bg: rgba(255, 55, 95, 0.14);
|
|
33
|
+
--failure-bg: rgba(255, 105, 97, 0.12);
|
|
34
|
+
--session-boundary-bg: rgba(48, 209, 88, 0.06);
|
|
35
|
+
--user-prompt-bg: rgba(255, 214, 10, 0.06);
|
|
36
|
+
--user-prompt-text: #ffd60a;
|
|
37
|
+
|
|
38
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', system-ui, sans-serif;
|
|
39
|
+
--font-mono: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
[data-theme="light"] {
|
|
43
|
+
color-scheme: light;
|
|
44
|
+
--bg: #fafafa;
|
|
45
|
+
--surface: #ffffff;
|
|
46
|
+
--surface-hover: #f4f4f5;
|
|
47
|
+
--border: #e4e4e7;
|
|
48
|
+
--border-subtle: #ededef;
|
|
49
|
+
--text: #18181b;
|
|
50
|
+
--text-muted: #52525b;
|
|
51
|
+
--text-dim: #8a8a93;
|
|
52
|
+
--accent: #0066cc;
|
|
53
|
+
--accent-contrast: #ffffff;
|
|
54
|
+
--success: #1a9e3e;
|
|
55
|
+
--success-soft: rgba(26, 158, 62, 0.15);
|
|
56
|
+
--danger: #d8302a;
|
|
57
|
+
--danger-soft: #dc184a;
|
|
58
|
+
--warning: #c07300;
|
|
59
|
+
--yellow: #a5720d;
|
|
60
|
+
--info: #0b8aa4;
|
|
61
|
+
--purple: #7c2fbc;
|
|
62
|
+
--purple-soft: #8b3fd4;
|
|
63
|
+
--peach: #c13a35;
|
|
64
|
+
|
|
65
|
+
--event-latest-bg: #f4f4f5;
|
|
66
|
+
--needs-human-bg: rgba(220, 24, 74, 0.08);
|
|
67
|
+
--failure-bg: rgba(216, 48, 42, 0.07);
|
|
68
|
+
--session-boundary-bg: rgba(26, 158, 62, 0.06);
|
|
69
|
+
--user-prompt-bg: rgba(165, 114, 13, 0.06);
|
|
70
|
+
--user-prompt-text: #8a5f0b;
|
|
71
|
+
}
|
|
72
|
+
|
|
8
73
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
|
|
74
|
+
|
|
75
|
+
html, body { height: 100%; }
|
|
76
|
+
body {
|
|
77
|
+
font-family: var(--font-sans);
|
|
78
|
+
background: var(--bg);
|
|
79
|
+
color: var(--text);
|
|
80
|
+
font-size: 14px;
|
|
81
|
+
line-height: 1.5;
|
|
82
|
+
-webkit-font-smoothing: antialiased;
|
|
83
|
+
-moz-osx-font-smoothing: grayscale;
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.header {
|
|
90
|
+
padding: 18px 28px;
|
|
91
|
+
border-bottom: 1px solid var(--border);
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: space-between;
|
|
95
|
+
gap: 16px;
|
|
96
|
+
}
|
|
97
|
+
.header h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; }
|
|
98
|
+
.header .meta { display: flex; align-items: center; gap: 14px; }
|
|
99
|
+
.header .status { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
|
100
|
+
.header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; vertical-align: 1px; }
|
|
101
|
+
.header .status .dot.on { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
|
|
102
|
+
.header .status .dot.off { background: var(--danger); }
|
|
103
|
+
|
|
104
|
+
.theme-toggle {
|
|
105
|
+
background: transparent;
|
|
106
|
+
color: var(--text-muted);
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: 7px;
|
|
109
|
+
width: 34px;
|
|
110
|
+
height: 30px;
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
padding: 0;
|
|
116
|
+
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
|
117
|
+
}
|
|
118
|
+
.theme-toggle:hover { color: var(--text); border-color: var(--text-dim); background: var(--surface); }
|
|
119
|
+
.theme-toggle svg { width: 15px; height: 15px; }
|
|
120
|
+
.theme-toggle .icon-sun { display: block; }
|
|
121
|
+
.theme-toggle .icon-moon { display: none; }
|
|
122
|
+
[data-theme="light"] .theme-toggle .icon-sun { display: none; }
|
|
123
|
+
[data-theme="light"] .theme-toggle .icon-moon { display: block; }
|
|
124
|
+
|
|
125
|
+
.stats {
|
|
126
|
+
padding: 16px 28px;
|
|
127
|
+
display: flex;
|
|
128
|
+
gap: 36px;
|
|
129
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
130
|
+
}
|
|
131
|
+
.stat { display: flex; flex-direction: column; }
|
|
132
|
+
.stat .n {
|
|
133
|
+
font-size: 26px;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
letter-spacing: -0.02em;
|
|
136
|
+
font-variant-numeric: tabular-nums;
|
|
137
|
+
line-height: 1.1;
|
|
138
|
+
}
|
|
139
|
+
.stat .l {
|
|
140
|
+
font-size: 11px;
|
|
141
|
+
color: var(--text-muted);
|
|
142
|
+
text-transform: uppercase;
|
|
143
|
+
letter-spacing: 0.08em;
|
|
144
|
+
font-weight: 500;
|
|
145
|
+
margin-top: 4px;
|
|
146
|
+
}
|
|
147
|
+
.stat .n.alert { color: var(--danger-soft); }
|
|
148
|
+
|
|
149
|
+
.sessions {
|
|
150
|
+
padding: 12px 28px;
|
|
151
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
152
|
+
display: flex;
|
|
153
|
+
gap: 8px;
|
|
154
|
+
flex-wrap: wrap;
|
|
155
|
+
}
|
|
156
|
+
.session-tag {
|
|
157
|
+
padding: 4px 12px;
|
|
158
|
+
border-radius: 14px;
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
border: 1px solid transparent;
|
|
163
|
+
transition: border-color 0.15s ease;
|
|
164
|
+
}
|
|
165
|
+
.session-tag:hover { border-color: var(--text-dim); }
|
|
166
|
+
.session-tag.active { border-color: var(--text); }
|
|
167
|
+
|
|
168
|
+
.filters {
|
|
169
|
+
padding: 10px 28px;
|
|
170
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
171
|
+
display: flex;
|
|
172
|
+
gap: 10px;
|
|
173
|
+
flex-wrap: wrap;
|
|
174
|
+
align-items: center;
|
|
175
|
+
}
|
|
176
|
+
.filters select, .filters button {
|
|
177
|
+
background: var(--surface);
|
|
178
|
+
color: var(--text-muted);
|
|
179
|
+
border: 1px solid var(--border);
|
|
180
|
+
border-radius: 6px;
|
|
181
|
+
padding: 0 12px;
|
|
182
|
+
font-size: 13px;
|
|
183
|
+
font-family: inherit;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
height: 32px;
|
|
186
|
+
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
|
187
|
+
}
|
|
188
|
+
.filters select:hover, .filters button:hover { border-color: var(--text-dim); color: var(--text); }
|
|
189
|
+
.filters select:focus { outline: none; border-color: var(--accent); }
|
|
190
|
+
.filters button.active { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
|
191
|
+
|
|
192
|
+
.timeline { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0; }
|
|
193
|
+
|
|
194
|
+
.event {
|
|
195
|
+
padding: 14px 28px;
|
|
196
|
+
display: grid;
|
|
197
|
+
grid-template-columns: 82px 150px 170px 90px 1fr;
|
|
198
|
+
gap: 14px;
|
|
199
|
+
align-items: start;
|
|
200
|
+
font-size: 14px;
|
|
201
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
202
|
+
transition: background-color 0.15s ease, opacity 0.15s ease;
|
|
203
|
+
opacity: 0.82;
|
|
204
|
+
}
|
|
205
|
+
.event:hover { background: var(--surface-hover); opacity: 1; }
|
|
206
|
+
.event .time {
|
|
207
|
+
color: var(--text-dim);
|
|
208
|
+
font-variant-numeric: tabular-nums;
|
|
209
|
+
font-family: var(--font-mono);
|
|
210
|
+
font-size: 12.5px;
|
|
211
|
+
padding-top: 2px;
|
|
212
|
+
}
|
|
213
|
+
.event .type {
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
font-size: 14px;
|
|
216
|
+
letter-spacing: -0.01em;
|
|
217
|
+
}
|
|
218
|
+
.event .session {
|
|
219
|
+
font-size: 12.5px;
|
|
220
|
+
font-weight: 500;
|
|
221
|
+
border-radius: 10px;
|
|
222
|
+
padding: 3px 10px;
|
|
223
|
+
display: inline-block;
|
|
224
|
+
max-width: 170px;
|
|
225
|
+
overflow: hidden;
|
|
226
|
+
text-overflow: ellipsis;
|
|
227
|
+
white-space: nowrap;
|
|
228
|
+
}
|
|
229
|
+
.event .tool {
|
|
230
|
+
color: var(--text-muted);
|
|
231
|
+
font-family: var(--font-mono);
|
|
232
|
+
font-size: 12.5px;
|
|
233
|
+
padding-top: 2px;
|
|
234
|
+
}
|
|
235
|
+
.event .detail {
|
|
236
|
+
color: var(--text-muted);
|
|
237
|
+
font-size: 14px;
|
|
238
|
+
line-height: 1.55;
|
|
239
|
+
word-break: break-word;
|
|
240
|
+
}
|
|
241
|
+
.event .detail .filepath { color: var(--info); }
|
|
242
|
+
.event .detail .cmd { color: var(--yellow); font-family: var(--font-mono); }
|
|
243
|
+
|
|
244
|
+
.event.latest {
|
|
245
|
+
opacity: 1;
|
|
246
|
+
background: var(--event-latest-bg);
|
|
247
|
+
border-bottom: 2px solid var(--border);
|
|
248
|
+
}
|
|
249
|
+
.event.latest .type { font-size: 15px; }
|
|
250
|
+
.event.latest .detail { font-size: 15px; color: var(--text); }
|
|
251
|
+
|
|
252
|
+
.event.needs-human {
|
|
253
|
+
background: var(--needs-human-bg);
|
|
254
|
+
border-left: 4px solid var(--danger-soft);
|
|
255
|
+
padding-left: 24px;
|
|
256
|
+
opacity: 1;
|
|
257
|
+
}
|
|
258
|
+
.event.needs-human .type { color: var(--danger-soft); }
|
|
259
|
+
.event.needs-human .human-badge {
|
|
260
|
+
display: inline-block;
|
|
261
|
+
background: var(--danger-soft);
|
|
262
|
+
color: #fff;
|
|
263
|
+
font-size: 10px;
|
|
264
|
+
font-weight: 700;
|
|
265
|
+
padding: 2px 7px;
|
|
266
|
+
border-radius: 4px;
|
|
267
|
+
letter-spacing: 0.06em;
|
|
268
|
+
margin-left: 10px;
|
|
269
|
+
vertical-align: middle;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.event.failure {
|
|
273
|
+
background: var(--failure-bg);
|
|
274
|
+
border-left: 4px solid var(--peach);
|
|
275
|
+
padding-left: 24px;
|
|
276
|
+
opacity: 1;
|
|
277
|
+
}
|
|
278
|
+
.event.session-boundary { background: var(--session-boundary-bg); }
|
|
279
|
+
.event.user-prompt { background: var(--user-prompt-bg); }
|
|
280
|
+
.event.user-prompt .detail { color: var(--user-prompt-text); font-style: italic; }
|
|
281
|
+
|
|
282
|
+
.timeline-end { padding: 20px 24px; text-align: center; border-bottom: 1px solid var(--border-subtle); }
|
|
283
|
+
.loader { display: inline-block; width: 48px; height: 4px; position: relative; }
|
|
284
|
+
.loader::before {
|
|
285
|
+
content: '';
|
|
286
|
+
position: absolute;
|
|
287
|
+
width: 10px;
|
|
288
|
+
height: 4px;
|
|
289
|
+
background: var(--text-dim);
|
|
290
|
+
border-radius: 2px;
|
|
291
|
+
animation: loader 1.4s ease-in-out infinite;
|
|
292
|
+
}
|
|
69
293
|
@keyframes loader {
|
|
70
294
|
0%, 100% { left: 0; }
|
|
71
|
-
50% { left:
|
|
72
|
-
}
|
|
73
|
-
.timeline-end .waiting {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
.type-
|
|
295
|
+
50% { left: 38px; }
|
|
296
|
+
}
|
|
297
|
+
.timeline-end .waiting {
|
|
298
|
+
font-size: 13px;
|
|
299
|
+
color: var(--text-dim);
|
|
300
|
+
margin-top: 10px;
|
|
301
|
+
font-variant-numeric: tabular-nums;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.empty { padding: 72px 28px; text-align: center; color: var(--text-dim); }
|
|
305
|
+
.empty p { margin-top: 10px; font-size: 14px; }
|
|
306
|
+
.empty p strong { color: var(--text-muted); font-weight: 600; font-size: 15px; }
|
|
307
|
+
|
|
308
|
+
.timeline::-webkit-scrollbar { width: 10px; }
|
|
309
|
+
.timeline::-webkit-scrollbar-track { background: transparent; }
|
|
310
|
+
.timeline::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
|
|
311
|
+
.timeline::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
|
312
|
+
|
|
313
|
+
.type-SessionStart { color: var(--success); }
|
|
314
|
+
.type-SessionEnd, .type-Stop { color: var(--danger); }
|
|
315
|
+
.type-PreToolUse { color: var(--accent); }
|
|
316
|
+
.type-PostToolUse { color: var(--info); }
|
|
317
|
+
.type-PostToolUseFailure { color: var(--peach); }
|
|
318
|
+
.type-UserPromptSubmit { color: var(--yellow); }
|
|
319
|
+
.type-SubagentStart { color: var(--purple); }
|
|
320
|
+
.type-SubagentStop { color: var(--purple-soft); }
|
|
321
|
+
.type-Notification { color: var(--warning); }
|
|
322
|
+
.type-PermissionRequest { color: var(--danger-soft); }
|
|
323
|
+
.type-PreCompact { color: var(--text-muted); }
|
|
90
324
|
</style>
|
|
91
325
|
</head>
|
|
92
326
|
<body>
|
|
93
327
|
|
|
94
328
|
<div class="header">
|
|
95
329
|
<h1>UV Suite Watchtower</h1>
|
|
96
|
-
<div class="
|
|
330
|
+
<div class="meta">
|
|
331
|
+
<div class="status"><span class="dot" id="statusDot"></span><span id="statusText">Connecting...</span></div>
|
|
332
|
+
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme" title="Toggle theme">
|
|
333
|
+
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
334
|
+
<circle cx="12" cy="12" r="4"/>
|
|
335
|
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
336
|
+
</svg>
|
|
337
|
+
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
338
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
339
|
+
</svg>
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
97
342
|
</div>
|
|
98
343
|
|
|
99
344
|
<div class="stats">
|
|
@@ -308,6 +553,8 @@ function renderEvent(ev) {
|
|
|
308
553
|
<span class="detail">${eventDetail(ev)}</span>
|
|
309
554
|
`;
|
|
310
555
|
|
|
556
|
+
div._ev = ev;
|
|
557
|
+
|
|
311
558
|
// Check filters
|
|
312
559
|
const typeMatch = !selectedType || type === selectedType;
|
|
313
560
|
const sessMatch = !selectedSession || sid === selectedSession;
|
|
@@ -362,7 +609,14 @@ function updateStats() {
|
|
|
362
609
|
const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
|
|
363
610
|
document.getElementById('errorCount').textContent = errors;
|
|
364
611
|
document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
|
|
365
|
-
|
|
612
|
+
// Count sessions whose most recent event is waiting on a human — so the
|
|
613
|
+
// number drops back down once the session continues past the prompt.
|
|
614
|
+
const latestBySession = {};
|
|
615
|
+
for (const ev of events) {
|
|
616
|
+
const sid = ev.session_id || ev.source_app || 'unknown';
|
|
617
|
+
latestBySession[sid] = ev;
|
|
618
|
+
}
|
|
619
|
+
const humans = Object.values(latestBySession).filter(needsHuman).length;
|
|
366
620
|
document.getElementById('humanCount').textContent = humans;
|
|
367
621
|
document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
|
|
368
622
|
}
|
|
@@ -405,8 +659,9 @@ function refilter() {
|
|
|
405
659
|
selectedType = filterType.value;
|
|
406
660
|
selectedSession = filterSession.value;
|
|
407
661
|
const rows = timeline.querySelectorAll('.event');
|
|
408
|
-
rows.forEach((row
|
|
409
|
-
const ev =
|
|
662
|
+
rows.forEach((row) => {
|
|
663
|
+
const ev = row._ev;
|
|
664
|
+
if (!ev) return;
|
|
410
665
|
const sid = ev.session_id || ev.source_app || 'unknown';
|
|
411
666
|
const type = ev.event_type || ev.hook_event_name || '';
|
|
412
667
|
const show = (!selectedType || type === selectedType)
|
|
@@ -456,6 +711,27 @@ function connect() {
|
|
|
456
711
|
};
|
|
457
712
|
}
|
|
458
713
|
|
|
714
|
+
// Theme: persist choice in localStorage, fall back to system preference
|
|
715
|
+
const THEME_KEY = 'uv-watchtower-theme';
|
|
716
|
+
const themeToggle = document.getElementById('themeToggle');
|
|
717
|
+
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
|
718
|
+
|
|
719
|
+
function applyTheme(t) { document.documentElement.setAttribute('data-theme', t); }
|
|
720
|
+
function resolvedTheme() {
|
|
721
|
+
return localStorage.getItem(THEME_KEY) || (mql.matches ? 'light' : 'dark');
|
|
722
|
+
}
|
|
723
|
+
applyTheme(resolvedTheme());
|
|
724
|
+
|
|
725
|
+
themeToggle.onclick = () => {
|
|
726
|
+
const next = resolvedTheme() === 'light' ? 'dark' : 'light';
|
|
727
|
+
localStorage.setItem(THEME_KEY, next);
|
|
728
|
+
applyTheme(next);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
mql.addEventListener('change', () => {
|
|
732
|
+
if (!localStorage.getItem(THEME_KEY)) applyTheme(resolvedTheme());
|
|
733
|
+
});
|
|
734
|
+
|
|
459
735
|
connect();
|
|
460
736
|
</script>
|
|
461
737
|
</body>
|
package/watchtower/server.js
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
// Zero dependencies beyond Node.js
|
|
5
5
|
// Uses Server-Sent Events (SSE) instead of WebSocket — simpler, auto-reconnects
|
|
6
6
|
|
|
7
|
-
const http = require(
|
|
8
|
-
const fs = require(
|
|
9
|
-
const path = require(
|
|
10
|
-
const crypto = require(
|
|
7
|
+
const http = require("http");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const crypto = require("crypto");
|
|
11
11
|
|
|
12
12
|
const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
|
|
13
|
-
const DATA_FILE = path.join(__dirname,
|
|
13
|
+
const DATA_FILE = path.join(__dirname, "events.json");
|
|
14
14
|
const MAX_EVENTS = 500;
|
|
15
15
|
|
|
16
16
|
// In-memory event store
|
|
17
17
|
let events = [];
|
|
18
18
|
try {
|
|
19
19
|
if (fs.existsSync(DATA_FILE)) {
|
|
20
|
-
events = JSON.parse(fs.readFileSync(DATA_FILE,
|
|
20
|
+
events = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8"));
|
|
21
21
|
}
|
|
22
22
|
} catch (e) {
|
|
23
23
|
events = [];
|
|
@@ -50,20 +50,20 @@ function saveEvents() {
|
|
|
50
50
|
|
|
51
51
|
const server = http.createServer((req, res) => {
|
|
52
52
|
// CORS
|
|
53
|
-
res.setHeader(
|
|
54
|
-
res.setHeader(
|
|
55
|
-
res.setHeader(
|
|
53
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
54
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
55
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
56
56
|
|
|
57
|
-
if (req.method ===
|
|
57
|
+
if (req.method === "OPTIONS") {
|
|
58
58
|
res.writeHead(200);
|
|
59
59
|
return res.end();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// POST /events — receive hook events
|
|
63
|
-
if (req.method ===
|
|
64
|
-
let body =
|
|
65
|
-
req.on(
|
|
66
|
-
req.on(
|
|
63
|
+
if (req.method === "POST" && req.url === "/events") {
|
|
64
|
+
let body = "";
|
|
65
|
+
req.on("data", (chunk) => (body += chunk));
|
|
66
|
+
req.on("end", () => {
|
|
67
67
|
try {
|
|
68
68
|
const event = JSON.parse(body);
|
|
69
69
|
event._ts = Date.now();
|
|
@@ -71,7 +71,7 @@ const server = http.createServer((req, res) => {
|
|
|
71
71
|
events.push(event);
|
|
72
72
|
broadcast(event);
|
|
73
73
|
saveEvents();
|
|
74
|
-
res.writeHead(200, {
|
|
74
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
75
75
|
res.end('{"ok":true}');
|
|
76
76
|
} catch (e) {
|
|
77
77
|
res.writeHead(400);
|
|
@@ -82,24 +82,30 @@ const server = http.createServer((req, res) => {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// GET /stream — SSE endpoint (replaces WebSocket)
|
|
85
|
-
if (req.method ===
|
|
85
|
+
if (req.method === "GET" && req.url === "/stream") {
|
|
86
86
|
res.writeHead(200, {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
"Content-Type": "text/event-stream",
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
Connection: "keep-alive",
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
// Send recent events as init
|
|
93
|
-
res.write(
|
|
93
|
+
res.write(
|
|
94
|
+
`data: ${JSON.stringify({ type: "init", events: events.slice(-100) })}\n\n`,
|
|
95
|
+
);
|
|
94
96
|
|
|
95
97
|
sseClients.add(res);
|
|
96
98
|
|
|
97
99
|
// Keep-alive ping every 15 seconds
|
|
98
100
|
const keepAlive = setInterval(() => {
|
|
99
|
-
try {
|
|
101
|
+
try {
|
|
102
|
+
res.write(": ping\n\n");
|
|
103
|
+
} catch (e) {
|
|
104
|
+
clearInterval(keepAlive);
|
|
105
|
+
}
|
|
100
106
|
}, 15000);
|
|
101
107
|
|
|
102
|
-
req.on(
|
|
108
|
+
req.on("close", () => {
|
|
103
109
|
sseClients.delete(res);
|
|
104
110
|
clearInterval(keepAlive);
|
|
105
111
|
});
|
|
@@ -107,26 +113,67 @@ const server = http.createServer((req, res) => {
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
// GET /events — fetch recent events (REST fallback)
|
|
110
|
-
if (req.method ===
|
|
111
|
-
res.writeHead(200, {
|
|
116
|
+
if (req.method === "GET" && req.url.startsWith("/events")) {
|
|
117
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
112
118
|
res.end(JSON.stringify(events.slice(-100)));
|
|
113
119
|
return;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
// GET / — serve dashboard
|
|
117
|
-
if (req.method ===
|
|
118
|
-
const html = fs.readFileSync(
|
|
119
|
-
|
|
123
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
|
|
124
|
+
const html = fs.readFileSync(
|
|
125
|
+
path.join(__dirname, "dashboard.html"),
|
|
126
|
+
"utf-8",
|
|
127
|
+
);
|
|
128
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
120
129
|
res.end(html);
|
|
121
130
|
return;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
res.writeHead(404);
|
|
125
|
-
res.end(
|
|
134
|
+
res.end("not found");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.on("error", (err) => {
|
|
138
|
+
if (err.code !== "EADDRINUSE") {
|
|
139
|
+
console.error("Watchtower server error:", err.message);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
// Port busy — probe to see if it's an existing watchtower or another process
|
|
143
|
+
const req = http.request(
|
|
144
|
+
{ host: "127.0.0.1", port: PORT, path: "/", method: "GET", timeout: 1500 },
|
|
145
|
+
(res) => {
|
|
146
|
+
let body = "";
|
|
147
|
+
res.on("data", (c) => (body += c));
|
|
148
|
+
res.on("end", () => {
|
|
149
|
+
if (/UV Suite Watchtower/.test(body)) {
|
|
150
|
+
console.log(
|
|
151
|
+
`UV Suite Watchtower is already running at http://localhost:${PORT}`,
|
|
152
|
+
);
|
|
153
|
+
process.exit(0);
|
|
154
|
+
} else {
|
|
155
|
+
console.error(`Port ${PORT} is in use by another process.`);
|
|
156
|
+
console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
req.on("error", () => {
|
|
163
|
+
console.error(`Port ${PORT} is in use but not responding.`);
|
|
164
|
+
console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
167
|
+
req.on("timeout", () => {
|
|
168
|
+
req.destroy();
|
|
169
|
+
});
|
|
170
|
+
req.end();
|
|
126
171
|
});
|
|
127
172
|
|
|
128
173
|
server.listen(PORT, () => {
|
|
129
174
|
console.log(`UV Suite Watchtower running at http://localhost:${PORT}`);
|
|
130
175
|
console.log(`${events.length} events loaded from disk`);
|
|
131
|
-
console.log(
|
|
176
|
+
console.log(
|
|
177
|
+
`Waiting for hook events on POST http://localhost:${PORT}/events`,
|
|
178
|
+
);
|
|
132
179
|
});
|