git-watchtower 1.9.19 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-watchtower.js +277 -2
- package/package.json +1 -1
- package/src/cli/args.js +29 -0
- package/src/config/loader.js +8 -0
- package/src/config/schema.js +25 -0
- package/src/index.js +14 -0
- package/src/server/coordinator.js +519 -0
- package/src/server/web-ui.js +2474 -0
- package/src/server/web.js +537 -0
- package/src/ui/keybindings.js +2 -0
- package/src/ui/renderer.js +1 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web dashboard server for Git Watchtower.
|
|
3
|
+
*
|
|
4
|
+
* Runs alongside the TUI, serving a browser-based dashboard that mirrors
|
|
5
|
+
* (and extends) the terminal UI. Uses SSE to push state updates to the
|
|
6
|
+
* browser and accepts POST actions from the web frontend.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies — uses only Node built-in http module.
|
|
9
|
+
*
|
|
10
|
+
* @module server/web
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const { getWebDashboardHtml } = require('./web-ui');
|
|
15
|
+
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
16
|
+
const sessionStats = require('../stats/session');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default web dashboard port
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_WEB_PORT = 4000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How often to push state to SSE clients (ms)
|
|
25
|
+
*/
|
|
26
|
+
const STATE_PUSH_INTERVAL = 500;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maximum number of port retries on EADDRINUSE
|
|
30
|
+
*/
|
|
31
|
+
const MAX_PORT_RETRIES = 20;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* SSE keepalive interval (ms) — prevents proxies from dropping idle connections
|
|
35
|
+
*/
|
|
36
|
+
const SSE_KEEPALIVE_INTERVAL = 15000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} WebDashboardOptions
|
|
40
|
+
* @property {number} [port=4000] - Port to listen on
|
|
41
|
+
* @property {import('../state/store').Store} store - State store instance
|
|
42
|
+
* @property {function} [onAction] - Callback for web UI actions
|
|
43
|
+
* @property {function} [getExtraState] - Returns additional state to merge
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Web dashboard server.
|
|
48
|
+
* Manages an HTTP server, SSE connections, and state broadcasting.
|
|
49
|
+
*/
|
|
50
|
+
class WebDashboardServer {
|
|
51
|
+
/**
|
|
52
|
+
* @param {WebDashboardOptions} options
|
|
53
|
+
*/
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.port = options.port || DEFAULT_WEB_PORT;
|
|
56
|
+
this.store = options.store;
|
|
57
|
+
this.onAction = options.onAction || (() => {});
|
|
58
|
+
this.getExtraState = options.getExtraState || (() => ({}));
|
|
59
|
+
|
|
60
|
+
/** @type {Set<import('http').ServerResponse>} */
|
|
61
|
+
this.clients = new Set();
|
|
62
|
+
this.server = null;
|
|
63
|
+
this.pushInterval = null;
|
|
64
|
+
this.lastPushedJson = '';
|
|
65
|
+
|
|
66
|
+
// Multi-project support (populated by coordinator)
|
|
67
|
+
/** @type {Map<string, Object>} */
|
|
68
|
+
this.projects = new Map();
|
|
69
|
+
this.localProjectId = null;
|
|
70
|
+
|
|
71
|
+
/** @type {string|null} Repository web URL for building links */
|
|
72
|
+
this.repoWebUrl = null;
|
|
73
|
+
|
|
74
|
+
// Cache the HTML (regenerated only if port changes)
|
|
75
|
+
this._cachedHtml = getWebDashboardHtml(this.port);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a JSON-serializable snapshot of the store state.
|
|
80
|
+
* Converts Maps to plain objects for JSON.stringify.
|
|
81
|
+
* @returns {Object}
|
|
82
|
+
*/
|
|
83
|
+
getSerializableState() {
|
|
84
|
+
const s = this.store.getState();
|
|
85
|
+
const extra = this.getExtraState();
|
|
86
|
+
|
|
87
|
+
// Convert Maps to plain objects
|
|
88
|
+
const sparklineCache = {};
|
|
89
|
+
if (s.sparklineCache instanceof Map) {
|
|
90
|
+
s.sparklineCache.forEach((v, k) => { sparklineCache[k] = v; });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const branchPrStatusMap = {};
|
|
94
|
+
if (s.branchPrStatusMap instanceof Map) {
|
|
95
|
+
s.branchPrStatusMap.forEach((v, k) => { branchPrStatusMap[k] = v; });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const aheadBehindCache = {};
|
|
99
|
+
if (s.aheadBehindCache instanceof Map) {
|
|
100
|
+
s.aheadBehindCache.forEach((v, k) => { aheadBehindCache[k] = v; });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
// Git state
|
|
105
|
+
branches: s.branches,
|
|
106
|
+
currentBranch: s.currentBranch,
|
|
107
|
+
isDetachedHead: s.isDetachedHead,
|
|
108
|
+
hasMergeConflict: s.hasMergeConflict,
|
|
109
|
+
|
|
110
|
+
// Polling
|
|
111
|
+
pollingStatus: s.pollingStatus,
|
|
112
|
+
isOffline: s.isOffline,
|
|
113
|
+
|
|
114
|
+
// Server
|
|
115
|
+
serverMode: s.serverMode,
|
|
116
|
+
serverRunning: s.serverRunning,
|
|
117
|
+
serverCrashed: s.serverCrashed,
|
|
118
|
+
port: s.port,
|
|
119
|
+
|
|
120
|
+
// UI
|
|
121
|
+
soundEnabled: s.soundEnabled,
|
|
122
|
+
projectName: s.projectName,
|
|
123
|
+
|
|
124
|
+
// Activity
|
|
125
|
+
activityLog: s.activityLog,
|
|
126
|
+
switchHistory: s.switchHistory,
|
|
127
|
+
|
|
128
|
+
// Server logs
|
|
129
|
+
serverLogBuffer: s.serverLogBuffer || [],
|
|
130
|
+
|
|
131
|
+
// Caches (as plain objects)
|
|
132
|
+
sparklineCache,
|
|
133
|
+
branchPrStatusMap,
|
|
134
|
+
aheadBehindCache,
|
|
135
|
+
|
|
136
|
+
// Metadata
|
|
137
|
+
version: PACKAGE_VERSION,
|
|
138
|
+
|
|
139
|
+
// Version update
|
|
140
|
+
updateAvailable: s.updateAvailable || null,
|
|
141
|
+
updateInProgress: s.updateInProgress || false,
|
|
142
|
+
|
|
143
|
+
// Server info
|
|
144
|
+
noServer: s.noServer || false,
|
|
145
|
+
clientCount: this.clients.size,
|
|
146
|
+
|
|
147
|
+
// Session stats
|
|
148
|
+
sessionStats: sessionStats.getStats(),
|
|
149
|
+
|
|
150
|
+
// Multi-project data
|
|
151
|
+
projects: this._getProjectsList(),
|
|
152
|
+
activeProjectId: this.localProjectId,
|
|
153
|
+
|
|
154
|
+
// Repository web URL for building links in the web UI
|
|
155
|
+
repoWebUrl: this.repoWebUrl || null,
|
|
156
|
+
|
|
157
|
+
// Extra state from the main process
|
|
158
|
+
...extra,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Update the full projects list (called by coordinator).
|
|
164
|
+
* @param {Array<{id: string, projectName: string, projectPath: string, state: Object}>} projects
|
|
165
|
+
*/
|
|
166
|
+
setProjects(projects) {
|
|
167
|
+
this.projects.clear();
|
|
168
|
+
for (const p of projects) {
|
|
169
|
+
this.projects.set(p.id, p);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Set the local project ID.
|
|
175
|
+
* @param {string} id
|
|
176
|
+
*/
|
|
177
|
+
setLocalProjectId(id) {
|
|
178
|
+
this.localProjectId = id;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Set the repository web URL for link building in the web UI.
|
|
183
|
+
* @param {string|null} url - e.g. https://github.com/user/repo
|
|
184
|
+
*/
|
|
185
|
+
setRepoWebUrl(url) {
|
|
186
|
+
this.repoWebUrl = url || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get a serializable state for a specific project (by ID).
|
|
191
|
+
* @param {string} projectId
|
|
192
|
+
* @returns {Object|null}
|
|
193
|
+
*/
|
|
194
|
+
getProjectState(projectId) {
|
|
195
|
+
if (projectId === this.localProjectId) {
|
|
196
|
+
return this.getSerializableState();
|
|
197
|
+
}
|
|
198
|
+
const project = this.projects.get(projectId);
|
|
199
|
+
return project ? project.state : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get projects list for the frontend.
|
|
204
|
+
* @returns {Array<{id: string, name: string, active: boolean}>}
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
_getProjectsList() {
|
|
208
|
+
const list = [];
|
|
209
|
+
for (const [id, p] of this.projects) {
|
|
210
|
+
list.push({
|
|
211
|
+
id,
|
|
212
|
+
name: p.projectName,
|
|
213
|
+
path: p.projectPath,
|
|
214
|
+
active: id === this.localProjectId,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// If no projects from coordinator, at least show ourselves
|
|
218
|
+
if (list.length === 0 && this.localProjectId) {
|
|
219
|
+
const s = this.store.getState();
|
|
220
|
+
list.push({
|
|
221
|
+
id: this.localProjectId,
|
|
222
|
+
name: s.projectName || 'unknown',
|
|
223
|
+
path: '',
|
|
224
|
+
active: true,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return list;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Start the web dashboard server.
|
|
232
|
+
* @returns {Promise<{port: number}>} Resolves when the server is listening
|
|
233
|
+
*/
|
|
234
|
+
start() {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let retries = 0;
|
|
237
|
+
|
|
238
|
+
this.server = http.createServer((req, res) => {
|
|
239
|
+
this._handleRequest(req, res);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
this.server.on('error', (/** @type {Error & {code?: string}} */ err) => {
|
|
243
|
+
if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
|
|
244
|
+
retries++;
|
|
245
|
+
this.port++;
|
|
246
|
+
this._cachedHtml = getWebDashboardHtml(this.port);
|
|
247
|
+
this.server.listen(this.port, '127.0.0.1');
|
|
248
|
+
} else {
|
|
249
|
+
reject(err);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
254
|
+
// Start pushing state to clients
|
|
255
|
+
this.pushInterval = setInterval(() => {
|
|
256
|
+
this._pushState();
|
|
257
|
+
}, STATE_PUSH_INTERVAL);
|
|
258
|
+
|
|
259
|
+
resolve({ port: this.port });
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Stop the web dashboard server.
|
|
266
|
+
*/
|
|
267
|
+
stop() {
|
|
268
|
+
if (this.pushInterval) {
|
|
269
|
+
clearInterval(this.pushInterval);
|
|
270
|
+
this.pushInterval = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Close all SSE connections
|
|
274
|
+
for (const client of this.clients) {
|
|
275
|
+
try { client.end(); } catch (e) { /* ignore */ }
|
|
276
|
+
}
|
|
277
|
+
this.clients.clear();
|
|
278
|
+
|
|
279
|
+
if (this.server) {
|
|
280
|
+
this.server.close();
|
|
281
|
+
this.server = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Push a flash message to all connected web clients.
|
|
287
|
+
* @param {string} text
|
|
288
|
+
* @param {string} [type='info']
|
|
289
|
+
*/
|
|
290
|
+
flash(text, type) {
|
|
291
|
+
const data = JSON.stringify({ text, type: type || 'info' });
|
|
292
|
+
for (const client of this.clients) {
|
|
293
|
+
try {
|
|
294
|
+
client.write('event: flash\n');
|
|
295
|
+
client.write('data: ' + data + '\n\n');
|
|
296
|
+
} catch (e) { /* ignore dead clients */ }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Send preview data to all connected clients.
|
|
302
|
+
* @param {Object} data - Preview data
|
|
303
|
+
*/
|
|
304
|
+
sendPreview(data) {
|
|
305
|
+
const json = JSON.stringify(data);
|
|
306
|
+
for (const client of this.clients) {
|
|
307
|
+
try {
|
|
308
|
+
client.write('event: preview\n');
|
|
309
|
+
client.write('data: ' + json + '\n\n');
|
|
310
|
+
} catch (e) { /* ignore dead clients */ }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Send action result feedback to all connected clients.
|
|
316
|
+
* @param {Object} result - { action, success, message, type }
|
|
317
|
+
*/
|
|
318
|
+
sendActionResult(result) {
|
|
319
|
+
const json = JSON.stringify(result);
|
|
320
|
+
for (const client of this.clients) {
|
|
321
|
+
try {
|
|
322
|
+
client.write('event: actionResult\n');
|
|
323
|
+
client.write('data: ' + json + '\n\n');
|
|
324
|
+
} catch (e) { /* ignore dead clients */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get the number of connected web clients.
|
|
330
|
+
* @returns {number}
|
|
331
|
+
*/
|
|
332
|
+
getClientCount() {
|
|
333
|
+
return this.clients.size;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Private ───────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Handle an incoming HTTP request.
|
|
340
|
+
* @param {import('http').IncomingMessage} req
|
|
341
|
+
* @param {import('http').ServerResponse} res
|
|
342
|
+
* @private
|
|
343
|
+
*/
|
|
344
|
+
_handleRequest(req, res) {
|
|
345
|
+
const url = new URL(req.url, `http://localhost:${this.port}`);
|
|
346
|
+
const pathname = url.pathname;
|
|
347
|
+
|
|
348
|
+
// CORS — restrict to own origin
|
|
349
|
+
const origin = `http://localhost:${this.port}`;
|
|
350
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
351
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
352
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
353
|
+
|
|
354
|
+
if (req.method === 'OPTIONS') {
|
|
355
|
+
res.writeHead(204);
|
|
356
|
+
res.end();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Routes
|
|
361
|
+
if (pathname === '/' && req.method === 'GET') {
|
|
362
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
363
|
+
res.end(this._cachedHtml);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (pathname === '/api/state' && req.method === 'GET') {
|
|
368
|
+
const state = this.getSerializableState();
|
|
369
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
370
|
+
res.end(JSON.stringify(state));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (pathname === '/api/events' && req.method === 'GET') {
|
|
375
|
+
this._handleSSE(req, res);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (pathname === '/api/projects' && req.method === 'GET') {
|
|
380
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
381
|
+
res.end(JSON.stringify(this._getProjectsList()));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// /api/projects/:id/state
|
|
386
|
+
const projectStateMatch = pathname.match(/^\/api\/projects\/([a-zA-Z0-9_-]+)\/state$/);
|
|
387
|
+
if (projectStateMatch && req.method === 'GET') {
|
|
388
|
+
const projectState = this.getProjectState(projectStateMatch[1]);
|
|
389
|
+
if (projectState) {
|
|
390
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
391
|
+
res.end(JSON.stringify(projectState));
|
|
392
|
+
} else {
|
|
393
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
394
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (pathname === '/api/action' && req.method === 'POST') {
|
|
400
|
+
this._handleAction(req, res);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 404
|
|
405
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
406
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Set up an SSE connection.
|
|
411
|
+
* @param {import('http').IncomingMessage} req
|
|
412
|
+
* @param {import('http').ServerResponse} res
|
|
413
|
+
* @private
|
|
414
|
+
*/
|
|
415
|
+
_handleSSE(req, res) {
|
|
416
|
+
res.writeHead(200, {
|
|
417
|
+
'Content-Type': 'text/event-stream',
|
|
418
|
+
'Cache-Control': 'no-cache',
|
|
419
|
+
'Connection': 'keep-alive',
|
|
420
|
+
'X-Accel-Buffering': 'no',
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Send initial state immediately
|
|
424
|
+
const state = this.getSerializableState();
|
|
425
|
+
res.write('event: state\n');
|
|
426
|
+
res.write('data: ' + JSON.stringify(state) + '\n\n');
|
|
427
|
+
|
|
428
|
+
this.clients.add(res);
|
|
429
|
+
|
|
430
|
+
// Keepalive heartbeat to prevent proxy/LB timeouts
|
|
431
|
+
const keepalive = setInterval(() => {
|
|
432
|
+
try { res.write(': keepalive\\n\\n'); } catch (e) { clearInterval(keepalive); }
|
|
433
|
+
}, SSE_KEEPALIVE_INTERVAL);
|
|
434
|
+
|
|
435
|
+
req.on('close', () => {
|
|
436
|
+
clearInterval(keepalive);
|
|
437
|
+
this.clients.delete(res);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Handle a POST action from the web UI.
|
|
443
|
+
* @param {import('http').IncomingMessage} req
|
|
444
|
+
* @param {import('http').ServerResponse} res
|
|
445
|
+
* @private
|
|
446
|
+
*/
|
|
447
|
+
_handleAction(req, res) {
|
|
448
|
+
let body = '';
|
|
449
|
+
let aborted = false;
|
|
450
|
+
req.on('data', (chunk) => {
|
|
451
|
+
body += chunk;
|
|
452
|
+
// Limit body size to 10KB
|
|
453
|
+
if (body.length > 10240 && !aborted) {
|
|
454
|
+
aborted = true;
|
|
455
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
456
|
+
res.end(JSON.stringify({ error: 'Payload too large' }));
|
|
457
|
+
req.destroy();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
req.on('end', () => {
|
|
462
|
+
if (aborted) return;
|
|
463
|
+
try {
|
|
464
|
+
const data = JSON.parse(body);
|
|
465
|
+
const action = data.action;
|
|
466
|
+
const payload = data.payload || {};
|
|
467
|
+
|
|
468
|
+
if (!action || typeof action !== 'string') {
|
|
469
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
470
|
+
res.end(JSON.stringify({ error: 'Missing action' }));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Whitelist allowed actions
|
|
475
|
+
const allowedActions = [
|
|
476
|
+
'switchBranch', 'pull', 'fetch', 'undo',
|
|
477
|
+
'toggleSound', 'preview',
|
|
478
|
+
'restartServer', 'reloadBrowsers', 'toggleCasino',
|
|
479
|
+
'openBrowser',
|
|
480
|
+
'stash', 'stashPop', 'deleteBranches', 'checkUpdate',
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
if (!allowedActions.includes(action)) {
|
|
484
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
485
|
+
res.end(JSON.stringify({ error: 'Unknown action: ' + action }));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Include projectId if provided (for multi-project routing)
|
|
490
|
+
const projectId = data.projectId || this.localProjectId;
|
|
491
|
+
payload._projectId = projectId;
|
|
492
|
+
|
|
493
|
+
// Dispatch to the main process
|
|
494
|
+
this.onAction(action, payload);
|
|
495
|
+
|
|
496
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
497
|
+
res.end(JSON.stringify({ ok: true }));
|
|
498
|
+
} catch (e) {
|
|
499
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
500
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Push current state to all SSE clients (if changed).
|
|
507
|
+
* @private
|
|
508
|
+
*/
|
|
509
|
+
_pushState() {
|
|
510
|
+
if (this.clients.size === 0) {
|
|
511
|
+
this.lastPushedJson = ''; // Invalidate so next client gets immediate state
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const state = this.getSerializableState();
|
|
516
|
+
const json = JSON.stringify(state);
|
|
517
|
+
|
|
518
|
+
// Only push if state changed
|
|
519
|
+
if (json === this.lastPushedJson) return;
|
|
520
|
+
this.lastPushedJson = json;
|
|
521
|
+
|
|
522
|
+
const message = 'event: state\ndata: ' + json + '\n\n';
|
|
523
|
+
for (const client of this.clients) {
|
|
524
|
+
try {
|
|
525
|
+
client.write(message);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
// Dead client — will be cleaned up on 'close'
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
module.exports = {
|
|
534
|
+
WebDashboardServer,
|
|
535
|
+
DEFAULT_WEB_PORT,
|
|
536
|
+
STATE_PUSH_INTERVAL,
|
|
537
|
+
};
|
package/src/ui/keybindings.js
CHANGED
package/src/ui/renderer.js
CHANGED
|
@@ -538,6 +538,7 @@ function renderFooter(state, write) {
|
|
|
538
538
|
}
|
|
539
539
|
|
|
540
540
|
write(ansi.gray + '[d]' + ansi.reset + ansi.bgBlack + ' Cleanup ');
|
|
541
|
+
write(ansi.gray + '[W]' + ansi.reset + ansi.bgBlack + ' Web ');
|
|
541
542
|
write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
|
|
542
543
|
write(ansi.reset);
|
|
543
544
|
}
|