opencode-dashboard 0.1.0 → 0.2.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/README.md +20 -1
- package/bin/cli.ts +16 -2
- package/dist/assets/{index-W-qyIr7d.js → index-DJanV0JQ.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugin/index.ts +245 -5
- package/server/index.ts +23 -3
- package/server/pid.ts +115 -5
- package/server/routes.ts +131 -0
- package/server/sse.ts +16 -0
- package/server/state.ts +518 -1
- package/shared/types.ts +17 -7
- package/shared/version.ts +46 -0
package/server/routes.ts
CHANGED
|
@@ -21,9 +21,16 @@ import {
|
|
|
21
21
|
createSSEResponse,
|
|
22
22
|
closeAllClients,
|
|
23
23
|
clientCount,
|
|
24
|
+
setClientCountChangeCallback,
|
|
24
25
|
reset as resetSSE,
|
|
25
26
|
} from "./sse";
|
|
26
27
|
import { join } from "path";
|
|
28
|
+
import { computeBuildHash } from "../shared/version";
|
|
29
|
+
|
|
30
|
+
// --- Build Hash ---
|
|
31
|
+
|
|
32
|
+
/** Computed once at module load time (same process as server/index.ts) */
|
|
33
|
+
const buildHash = computeBuildHash();
|
|
27
34
|
|
|
28
35
|
// --- Static File Serving ---
|
|
29
36
|
|
|
@@ -51,6 +58,36 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
51
58
|
/** Central state manager — processes events and persists to disk */
|
|
52
59
|
export const stateManager = new StateManager();
|
|
53
60
|
|
|
61
|
+
// --- Column Visibility → SSE Bridge ---
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Listen for column visibility changes from the StateManager and broadcast
|
|
65
|
+
* them as `columns:update` SSE events to all connected dashboard frontends.
|
|
66
|
+
*
|
|
67
|
+
* The StateManager emits `columns:visibility` events (via its onEvent listener
|
|
68
|
+
* mechanism) whenever broadcastColumnsUpdate() detects that the visible column
|
|
69
|
+
* set has changed. This bridge forwards those events to SSE clients so the
|
|
70
|
+
* frontend can update its column layout in real time.
|
|
71
|
+
*
|
|
72
|
+
* The StateManager already handles:
|
|
73
|
+
* - Deduplication (only emits when visible set actually changes)
|
|
74
|
+
* - Grace period timers for pipeline column hiding
|
|
75
|
+
* - Only triggering on column-affecting events (bead:stage, bead:done, etc.)
|
|
76
|
+
*/
|
|
77
|
+
const _unsubscribeColumnsListener = stateManager.onEvent((event, data) => {
|
|
78
|
+
if (event === "columns:visibility") {
|
|
79
|
+
const { projectPath, visibleColumns } = data as {
|
|
80
|
+
projectPath: string;
|
|
81
|
+
visibleColumns: unknown[];
|
|
82
|
+
};
|
|
83
|
+
broadcast("columns:update", {
|
|
84
|
+
projectPath,
|
|
85
|
+
visibleColumns,
|
|
86
|
+
_serverTimestamp: Date.now(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
54
91
|
// --- Plugin Registry ---
|
|
55
92
|
|
|
56
93
|
interface PluginRecord {
|
|
@@ -67,6 +104,91 @@ const plugins = new Map<string, PluginRecord>();
|
|
|
67
104
|
/** Server start time for uptime calculation */
|
|
68
105
|
const startTime = Date.now();
|
|
69
106
|
|
|
107
|
+
// --- Idle Auto-Shutdown ---
|
|
108
|
+
|
|
109
|
+
/** Handle for the idle shutdown timer (null when not ticking) */
|
|
110
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
|
|
112
|
+
/** Idle timeout in milliseconds; 0 or negative disables the feature */
|
|
113
|
+
let idleTimeoutMs: number = 300_000;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Configure the idle auto-shutdown feature.
|
|
117
|
+
*
|
|
118
|
+
* Sets the idle timeout value and registers a callback on SSE client
|
|
119
|
+
* count changes so that the idle timer is started/cleared automatically
|
|
120
|
+
* when all dashboard browser tabs connect or disconnect.
|
|
121
|
+
*/
|
|
122
|
+
export function configureIdleShutdown(timeoutMs: number): void {
|
|
123
|
+
idleTimeoutMs = timeoutMs;
|
|
124
|
+
|
|
125
|
+
setClientCountChangeCallback(() => {
|
|
126
|
+
if (clientCount() === 0) {
|
|
127
|
+
checkAndStartIdleTimer();
|
|
128
|
+
} else {
|
|
129
|
+
clearIdleTimer();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Start the idle shutdown timer if the server is truly idle
|
|
136
|
+
* (no registered plugins AND no connected SSE clients).
|
|
137
|
+
*
|
|
138
|
+
* Does nothing if:
|
|
139
|
+
* - The feature is disabled (idleTimeoutMs <= 0)
|
|
140
|
+
* - There are still plugins or clients connected
|
|
141
|
+
* - A timer is already ticking
|
|
142
|
+
*/
|
|
143
|
+
export function checkAndStartIdleTimer(): void {
|
|
144
|
+
if (idleTimeoutMs <= 0) return;
|
|
145
|
+
|
|
146
|
+
if (plugins.size > 0 || clientCount() > 0) {
|
|
147
|
+
clearIdleTimer();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (idleTimer !== null) return; // already ticking
|
|
152
|
+
|
|
153
|
+
const timeoutSec = Math.round(idleTimeoutMs / 1000);
|
|
154
|
+
console.log(
|
|
155
|
+
`[server] No plugins or clients connected. Auto-shutdown in ${timeoutSec}s...`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
idleTimer = setTimeout(() => {
|
|
159
|
+
idleTimer = null;
|
|
160
|
+
// Race-condition guard: re-check conditions before pulling the trigger
|
|
161
|
+
if (plugins.size > 0 || clientCount() > 0) {
|
|
162
|
+
console.log(
|
|
163
|
+
"[server] Idle timer fired but activity detected — shutdown aborted."
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
console.log("[server] Idle timeout reached — shutting down.");
|
|
168
|
+
// Dynamic require to avoid circular dependency at module load time:
|
|
169
|
+
// routes.ts ↔ index.ts both import from each other, but shutdown()
|
|
170
|
+
// is only needed at runtime (inside a setTimeout callback), so lazy
|
|
171
|
+
// resolution via require() defers the import until it's actually called.
|
|
172
|
+
const { shutdown: performShutdown } = require("./index");
|
|
173
|
+
performShutdown();
|
|
174
|
+
}, idleTimeoutMs);
|
|
175
|
+
|
|
176
|
+
// Don't let the timer keep the process alive on its own
|
|
177
|
+
idleTimer.unref();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Cancel a running idle shutdown timer (e.g. when a plugin registers
|
|
182
|
+
* or an SSE client connects).
|
|
183
|
+
*/
|
|
184
|
+
export function clearIdleTimer(): void {
|
|
185
|
+
if (idleTimer !== null) {
|
|
186
|
+
clearTimeout(idleTimer);
|
|
187
|
+
idleTimer = null;
|
|
188
|
+
console.log("[server] Idle timer cancelled — activity detected.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
70
192
|
// --- Helpers ---
|
|
71
193
|
|
|
72
194
|
/** Add CORS headers for localhost origins */
|
|
@@ -171,6 +293,9 @@ async function handleRegister(
|
|
|
171
293
|
pluginId,
|
|
172
294
|
});
|
|
173
295
|
|
|
296
|
+
// A new plugin means activity — cancel any pending idle shutdown
|
|
297
|
+
clearIdleTimer();
|
|
298
|
+
|
|
174
299
|
return json({ pluginId }, 200, origin);
|
|
175
300
|
}
|
|
176
301
|
|
|
@@ -334,6 +459,9 @@ function handleDeregister(
|
|
|
334
459
|
pluginId,
|
|
335
460
|
});
|
|
336
461
|
|
|
462
|
+
// Plugin removed — check if the server is now idle
|
|
463
|
+
checkAndStartIdleTimer();
|
|
464
|
+
|
|
337
465
|
return json({ ok: true }, 200, origin);
|
|
338
466
|
}
|
|
339
467
|
|
|
@@ -378,6 +506,7 @@ function handleHealth(origin?: string | null): Response {
|
|
|
378
506
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
379
507
|
plugins: plugins.size,
|
|
380
508
|
sseClients: clientCount(),
|
|
509
|
+
buildHash,
|
|
381
510
|
},
|
|
382
511
|
200,
|
|
383
512
|
origin
|
|
@@ -506,6 +635,8 @@ const pluginHealthInterval = setInterval(() => {
|
|
|
506
635
|
pluginId,
|
|
507
636
|
reason: "heartbeat_timeout",
|
|
508
637
|
});
|
|
638
|
+
// Plugin timed out — check if server is now idle
|
|
639
|
+
checkAndStartIdleTimer();
|
|
509
640
|
}
|
|
510
641
|
}
|
|
511
642
|
}, PLUGIN_HEALTH_CHECK_INTERVAL_MS);
|
package/server/sse.ts
CHANGED
|
@@ -27,6 +27,17 @@ const clients = new Set<SSEClient>();
|
|
|
27
27
|
|
|
28
28
|
const encoder = new TextEncoder();
|
|
29
29
|
|
|
30
|
+
/** Optional callback invoked whenever the connected client count changes */
|
|
31
|
+
let onClientCountChange: (() => void) | null = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register a callback that fires whenever the SSE client count changes
|
|
35
|
+
* (connect, disconnect, or failed send/heartbeat removal).
|
|
36
|
+
*/
|
|
37
|
+
export function setClientCountChangeCallback(cb: () => void): void {
|
|
38
|
+
onClientCountChange = cb;
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* Get the current number of connected SSE clients.
|
|
32
43
|
*/
|
|
@@ -61,6 +72,7 @@ export function broadcast(eventName: string, data: unknown): void {
|
|
|
61
72
|
client.controller.enqueue(encoded);
|
|
62
73
|
} catch {
|
|
63
74
|
clients.delete(client);
|
|
75
|
+
try { onClientCountChange?.(); } catch { /* callback error must not disrupt broadcast loop */ }
|
|
64
76
|
}
|
|
65
77
|
}
|
|
66
78
|
}
|
|
@@ -97,6 +109,7 @@ export function createSSEResponse(
|
|
|
97
109
|
connectedAt: Date.now(),
|
|
98
110
|
};
|
|
99
111
|
clients.add(sseClient);
|
|
112
|
+
onClientCountChange?.();
|
|
100
113
|
|
|
101
114
|
// Set reconnect interval
|
|
102
115
|
controller.enqueue(encoder.encode("retry: 3000\n\n"));
|
|
@@ -122,6 +135,7 @@ export function createSSEResponse(
|
|
|
122
135
|
cancel() {
|
|
123
136
|
if (sseClient) {
|
|
124
137
|
clients.delete(sseClient);
|
|
138
|
+
onClientCountChange?.();
|
|
125
139
|
}
|
|
126
140
|
},
|
|
127
141
|
});
|
|
@@ -152,6 +166,7 @@ const sseHeartbeatInterval = setInterval(() => {
|
|
|
152
166
|
client.controller.enqueue(heartbeat);
|
|
153
167
|
} catch {
|
|
154
168
|
clients.delete(client);
|
|
169
|
+
try { onClientCountChange?.(); } catch { /* callback error must not disrupt heartbeat loop */ }
|
|
155
170
|
}
|
|
156
171
|
}
|
|
157
172
|
}, SSE_HEARTBEAT_INTERVAL_MS);
|
|
@@ -189,6 +204,7 @@ export function reset(): void {
|
|
|
189
204
|
}
|
|
190
205
|
clients.clear();
|
|
191
206
|
messageId = 0;
|
|
207
|
+
onClientCountChange = null;
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
// --- Exports for testing ---
|