@ggui-ai/dev-stack 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +63 -0
- package/dist/dev-server/auth.d.ts +44 -0
- package/dist/dev-server/auth.d.ts.map +1 -0
- package/dist/dev-server/auth.js +176 -0
- package/dist/dev-server/events.d.ts +43 -0
- package/dist/dev-server/events.d.ts.map +1 -0
- package/dist/dev-server/events.js +61 -0
- package/dist/dev-server/http.d.ts +79 -0
- package/dist/dev-server/http.d.ts.map +1 -0
- package/dist/dev-server/http.js +277 -0
- package/dist/dev-server/hub-preview.d.ts +56 -0
- package/dist/dev-server/hub-preview.d.ts.map +1 -0
- package/dist/dev-server/hub-preview.js +237 -0
- package/dist/dev-server/hub.d.ts +59 -0
- package/dist/dev-server/hub.d.ts.map +1 -0
- package/dist/dev-server/hub.js +723 -0
- package/dist/hub-preview/client.js +52346 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/local-registry/compile-ui.d.ts +67 -0
- package/dist/local-registry/compile-ui.d.ts.map +1 -0
- package/dist/local-registry/compile-ui.js +148 -0
- package/dist/local-registry/local-registry.d.ts +93 -0
- package/dist/local-registry/local-registry.d.ts.map +1 -0
- package/dist/local-registry/local-registry.js +272 -0
- package/dist/local-registry/watcher.d.ts +42 -0
- package/dist/local-registry/watcher.d.ts.map +1 -0
- package/dist/local-registry/watcher.js +208 -0
- package/dist/run-dev.d.ts +147 -0
- package/dist/run-dev.d.ts.map +1 -0
- package/dist/run-dev.js +216 -0
- package/dist/runtime-supervisor.d.ts +140 -0
- package/dist/runtime-supervisor.d.ts.map +1 -0
- package/dist/runtime-supervisor.js +91 -0
- package/package.json +72 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the self-contained HTML document for the hub. Pure function
|
|
3
|
+
* so tests can exercise the rendered shape without binding a port.
|
|
4
|
+
*
|
|
5
|
+
* HTML escaping is applied to every string that bubbles up from
|
|
6
|
+
* user-controlled data (manifest fields, token). The token itself
|
|
7
|
+
* is hex in practice, but the escape is defensive — a future
|
|
8
|
+
* pairing-protocol token might be arbitrary.
|
|
9
|
+
*/
|
|
10
|
+
export function renderHubHtml(ctx) {
|
|
11
|
+
const appName = escapeHtml(ctx.manifest.app.name);
|
|
12
|
+
const appSlug = escapeHtml(ctx.manifest.app.slug);
|
|
13
|
+
const protocol = escapeHtml(ctx.manifest.protocol);
|
|
14
|
+
const bootstrap = JSON.stringify({
|
|
15
|
+
token: ctx.token,
|
|
16
|
+
app: { name: ctx.manifest.app.name, slug: ctx.manifest.app.slug },
|
|
17
|
+
protocol: ctx.manifest.protocol,
|
|
18
|
+
}).replace(/</g, '\\u003c');
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
24
|
+
<meta name="robots" content="noindex" />
|
|
25
|
+
<title>ggui dev — ${appName}</title>
|
|
26
|
+
<style>${HUB_CSS}</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<main id="app">
|
|
30
|
+
<header class="header">
|
|
31
|
+
<h1>ggui dev — ${appName}</h1>
|
|
32
|
+
<div class="subtitle">slug: ${appSlug} · protocol: ${protocol} · <span id="live" class="live">connecting…</span> · <span id="tick" class="tick">loading…</span></div>
|
|
33
|
+
</header>
|
|
34
|
+
<div class="grid">
|
|
35
|
+
<section class="panel" data-panel="runtime">
|
|
36
|
+
<h2>Runtime</h2>
|
|
37
|
+
<div id="runtime-body" class="panel-body"><div class="placeholder">Loading…</div></div>
|
|
38
|
+
</section>
|
|
39
|
+
<section class="panel" data-panel="registry">
|
|
40
|
+
<h2>Registry</h2>
|
|
41
|
+
<div id="registry-body" class="panel-body"><div class="placeholder">Loading…</div></div>
|
|
42
|
+
</section>
|
|
43
|
+
<section class="panel wide" data-panel="uis">
|
|
44
|
+
<h2>Discovered UIs <span id="uis-count" class="count"></span></h2>
|
|
45
|
+
<div id="uis-body" class="panel-body"><div class="placeholder">Loading…</div></div>
|
|
46
|
+
</section>
|
|
47
|
+
<section class="panel wide" data-panel="preview">
|
|
48
|
+
<div class="panel-head">
|
|
49
|
+
<h2>Preview <span id="preview-label" class="count"></span></h2>
|
|
50
|
+
<button id="preview-clear" class="preview-clear" type="button" hidden>clear</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="preview-body" class="panel-body preview-body">
|
|
53
|
+
<div class="placeholder">Click a UI above to preview it here.</div>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
<section class="panel wide" data-panel="events">
|
|
57
|
+
<h2>Runtime events <span id="events-count" class="count"></span></h2>
|
|
58
|
+
<div id="events-body" class="panel-body events"><div class="placeholder">Loading…</div></div>
|
|
59
|
+
</section>
|
|
60
|
+
<section class="panel wide" data-panel="diagnostics">
|
|
61
|
+
<h2>Diagnostics <span class="count">(click row to copy)</span></h2>
|
|
62
|
+
<div id="diagnostics-body" class="panel-body"></div>
|
|
63
|
+
<div class="hint">Keyboard: press <kbd>r</kbd> to refresh now.</div>
|
|
64
|
+
</section>
|
|
65
|
+
</div>
|
|
66
|
+
</main>
|
|
67
|
+
<script>window.__GGUI_DEV__ = ${bootstrap};</script>
|
|
68
|
+
<script>${HUB_JS}</script>
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write the hub shell to `res` with no-cache headers. Response is
|
|
75
|
+
* always 200 — there's no per-request failure mode for the shell.
|
|
76
|
+
*/
|
|
77
|
+
export function serveHubShell(_req, res, ctx) {
|
|
78
|
+
const html = renderHubHtml(ctx);
|
|
79
|
+
const body = Buffer.from(html, 'utf-8');
|
|
80
|
+
res.writeHead(200, {
|
|
81
|
+
'content-type': 'text/html; charset=utf-8',
|
|
82
|
+
'content-length': body.byteLength.toString(),
|
|
83
|
+
// Dev hub should never be cached — state changes every
|
|
84
|
+
// second and a stale shell masks real regressions.
|
|
85
|
+
'cache-control': 'no-store',
|
|
86
|
+
// Hub is a loopback UI; don't let it be embedded in a
|
|
87
|
+
// hostile iframe even hypothetically.
|
|
88
|
+
'x-frame-options': 'DENY',
|
|
89
|
+
});
|
|
90
|
+
res.end(body);
|
|
91
|
+
}
|
|
92
|
+
const HTML_ESCAPES = {
|
|
93
|
+
'&': '&',
|
|
94
|
+
'<': '<',
|
|
95
|
+
'>': '>',
|
|
96
|
+
'"': '"',
|
|
97
|
+
"'": ''',
|
|
98
|
+
};
|
|
99
|
+
function escapeHtml(value) {
|
|
100
|
+
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Hub CSS. Dark terminal-ish palette, monospace throughout,
|
|
104
|
+
* clearly code-first. No icons, no gradients, no animations beyond
|
|
105
|
+
* a single heartbeat pulse on the tick indicator.
|
|
106
|
+
*/
|
|
107
|
+
const HUB_CSS = `
|
|
108
|
+
:root { color-scheme: dark; }
|
|
109
|
+
* { box-sizing: border-box; }
|
|
110
|
+
html, body { margin: 0; padding: 0; background: #0a0a0a; color: #e7e7e7;
|
|
111
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
112
|
+
main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
|
113
|
+
h1 { font-size: 15px; margin: 0 0 4px; color: #fff; font-weight: 600; }
|
|
114
|
+
h2 { font-size: 11px; margin: 0 0 12px; color: #8a8a8a; font-weight: 600;
|
|
115
|
+
text-transform: uppercase; letter-spacing: 0.08em; }
|
|
116
|
+
.subtitle { color: #8a8a8a; font-size: 12px; margin-bottom: 20px; }
|
|
117
|
+
.tick { color: #666; }
|
|
118
|
+
.tick.ok::before { content: "● "; color: #3db37a; }
|
|
119
|
+
.tick.stale::before { content: "● "; color: #c99; }
|
|
120
|
+
.tick.err::before { content: "● "; color: #e57373; }
|
|
121
|
+
.live { color: #666; }
|
|
122
|
+
.live.live::before { content: "● "; color: #3db37a; animation: pulse 2s ease-in-out infinite; }
|
|
123
|
+
.live.reconnecting::before { content: "● "; color: #e0b870; animation: pulse 1s ease-in-out infinite; }
|
|
124
|
+
.live.offline::before { content: "● "; color: #666; }
|
|
125
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
126
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
127
|
+
.panel { border: 1px solid #1f1f1f; background: #0f0f0f; padding: 16px 18px; border-radius: 6px; }
|
|
128
|
+
.panel.wide { grid-column: 1 / -1; }
|
|
129
|
+
.panel-body { min-height: 48px; }
|
|
130
|
+
.placeholder { color: #555; font-style: italic; }
|
|
131
|
+
.row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; padding: 2px 0; }
|
|
132
|
+
.label { color: #8a8a8a; font-size: 12px; }
|
|
133
|
+
.value { color: #e7e7e7; word-break: break-all; }
|
|
134
|
+
.value.dim { color: #666; }
|
|
135
|
+
.badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-size: 11px;
|
|
136
|
+
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
137
|
+
.badge.absent { background: #1a1a1a; color: #666; border: 1px solid #262626; }
|
|
138
|
+
.badge.starting { background: #2a220d; color: #e0b870; border: 1px solid #3d3117; }
|
|
139
|
+
.badge.ready { background: #0f2a1a; color: #3db37a; border: 1px solid #1c4430; }
|
|
140
|
+
.badge.stopped { background: #1a1a1a; color: #888; border: 1px solid #262626; }
|
|
141
|
+
.badge.crashed { background: #2a0f0f; color: #e57373; border: 1px solid #441c1c; }
|
|
142
|
+
.badge.warn { background: #2a220d; color: #e0b870; border: 1px solid #3d3117; }
|
|
143
|
+
ul.uis { list-style: none; margin: 0; padding: 0; }
|
|
144
|
+
ul.uis li { padding: 0; border-top: 1px solid #181818; }
|
|
145
|
+
ul.uis li:first-child { border-top: none; }
|
|
146
|
+
ul.uis button.ui-row { display: flex; width: 100%; text-align: left;
|
|
147
|
+
gap: 12px; align-items: baseline; padding: 6px 4px; background: none;
|
|
148
|
+
border: 0; cursor: pointer; color: inherit; font: inherit;
|
|
149
|
+
border-radius: 3px; }
|
|
150
|
+
ul.uis button.ui-row:hover { background: #141414; }
|
|
151
|
+
ul.uis button.ui-row:focus-visible { outline: 2px solid #7cb2e8; outline-offset: -2px; }
|
|
152
|
+
ul.uis button.ui-row[aria-pressed="true"] { background: #0f2a1a; }
|
|
153
|
+
ul.uis .ui-id { color: #7cb2e8; font-family: inherit; }
|
|
154
|
+
ul.uis .ui-name { color: #8a8a8a; font-size: 12px; }
|
|
155
|
+
ul.uis .ui-hint { color: #555; font-size: 11px; margin-left: auto; }
|
|
156
|
+
.panel-head { display: flex; align-items: baseline; justify-content: space-between;
|
|
157
|
+
gap: 12px; margin: 0 0 12px; }
|
|
158
|
+
.panel-head h2 { margin: 0; }
|
|
159
|
+
button.preview-clear { background: none; border: 1px solid #262626;
|
|
160
|
+
color: #8a8a8a; font: inherit; font-size: 11px; padding: 2px 8px;
|
|
161
|
+
border-radius: 3px; cursor: pointer; }
|
|
162
|
+
button.preview-clear:hover { color: #e7e7e7; border-color: #3a3a3a; }
|
|
163
|
+
.preview-body { padding: 0; }
|
|
164
|
+
.preview-empty { padding: 32px; color: #666; text-align: center; font-style: italic; }
|
|
165
|
+
.preview-frame { width: 100%; min-height: 320px; border: 0; background: #0a0a0a;
|
|
166
|
+
display: block; }
|
|
167
|
+
.count { color: #555; font-weight: 400; }
|
|
168
|
+
.events { max-height: 320px; overflow-y: auto; font-size: 12px; }
|
|
169
|
+
.events .ev { display: grid; grid-template-columns: 88px 80px 1fr; gap: 8px;
|
|
170
|
+
padding: 2px 0; border-top: 1px solid #141414; }
|
|
171
|
+
.events .ev:first-child { border-top: none; }
|
|
172
|
+
.events .t { color: #555; }
|
|
173
|
+
.events .k-status { color: #3db37a; }
|
|
174
|
+
.events .k-log-stdout { color: #8a8a8a; }
|
|
175
|
+
.events .k-log-stderr { color: #d8a660; }
|
|
176
|
+
.events .k-error { color: #e57373; }
|
|
177
|
+
.events .msg { color: #ccc; white-space: pre-wrap; word-break: break-word; }
|
|
178
|
+
.diag-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
|
179
|
+
border-radius: 4px; cursor: pointer; color: #b8b8b8; }
|
|
180
|
+
.diag-row:hover { background: #161616; color: #e7e7e7; }
|
|
181
|
+
.diag-row.copied { background: #0f2a1a; color: #3db37a; }
|
|
182
|
+
.diag-label { width: 120px; color: #888; font-size: 11px; text-transform: uppercase;
|
|
183
|
+
letter-spacing: 0.06em; flex-shrink: 0; }
|
|
184
|
+
.diag-cmd { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis; }
|
|
186
|
+
.hint { color: #555; font-size: 11px; margin-top: 12px; padding-top: 12px;
|
|
187
|
+
border-top: 1px solid #181818; }
|
|
188
|
+
.hint kbd { background: #1a1a1a; border: 1px solid #262626; border-radius: 3px;
|
|
189
|
+
padding: 1px 6px; font-family: inherit; font-size: 11px; color: #ccc; }
|
|
190
|
+
@media (max-width: 640px) {
|
|
191
|
+
.grid { grid-template-columns: 1fr; }
|
|
192
|
+
.diag-row { flex-direction: column; align-items: flex-start; }
|
|
193
|
+
.diag-label { width: auto; }
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
/**
|
|
197
|
+
* Hub dashboard JS. Vanilla DOM, no framework.
|
|
198
|
+
*
|
|
199
|
+
* - Polls `/health`, `/uis`, `/runtime/status`, `/runtime/events`
|
|
200
|
+
* every 2 seconds as the baseline data loop.
|
|
201
|
+
* - Subscribes to `/events` via fetch-stream (EventSource can't
|
|
202
|
+
* send `Authorization`). On every `event: ui` frame, refreshes
|
|
203
|
+
* the registry-panel fetches immediately — so the `Discovered
|
|
204
|
+
* UIs` list + registry counts flip on save before the next
|
|
205
|
+
* poll tick fires. Same HMR mechanism Studio uses.
|
|
206
|
+
* - Live indicator (`#live`) paints connecting / live /
|
|
207
|
+
* reconnecting / offline; independent from the data-freshness
|
|
208
|
+
* tick.
|
|
209
|
+
* - Reconnects SSE with a small backoff; existing poll keeps
|
|
210
|
+
* data alive during the reconnect window.
|
|
211
|
+
*
|
|
212
|
+
* Runtime events stay on the poll loop for now — `/runtime/events`
|
|
213
|
+
* is a snapshot endpoint (SSE streaming is future hub scope).
|
|
214
|
+
*/
|
|
215
|
+
const HUB_JS = `
|
|
216
|
+
(function () {
|
|
217
|
+
'use strict';
|
|
218
|
+
var bootstrap = (window.__GGUI_DEV__ || {});
|
|
219
|
+
var token = bootstrap.token;
|
|
220
|
+
var headers = token ? { Authorization: 'Bearer ' + token } : {};
|
|
221
|
+
var POLL_MS = 2000;
|
|
222
|
+
var MAX_EVENTS = 80;
|
|
223
|
+
|
|
224
|
+
var el = {
|
|
225
|
+
tick: document.getElementById('tick'),
|
|
226
|
+
live: document.getElementById('live'),
|
|
227
|
+
runtime: document.getElementById('runtime-body'),
|
|
228
|
+
registry: document.getElementById('registry-body'),
|
|
229
|
+
uis: document.getElementById('uis-body'),
|
|
230
|
+
uisCount: document.getElementById('uis-count'),
|
|
231
|
+
events: document.getElementById('events-body'),
|
|
232
|
+
eventsCount: document.getElementById('events-count'),
|
|
233
|
+
diagnostics: document.getElementById('diagnostics-body'),
|
|
234
|
+
preview: document.getElementById('preview-body'),
|
|
235
|
+
previewLabel: document.getElementById('preview-label'),
|
|
236
|
+
previewClear: document.getElementById('preview-clear'),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Selected UI id, read from the URL on boot and kept in sync with
|
|
240
|
+
* history.replaceState. Kept as a module-level variable so
|
|
241
|
+
* renderUis + renderPreview stay coordinated. */
|
|
242
|
+
var selectedUi = readSelectedFromUrl();
|
|
243
|
+
// Remember the discovered list so selection changes don't have to
|
|
244
|
+
// re-fetch /uis before re-painting the row highlighting.
|
|
245
|
+
var lastUis = [];
|
|
246
|
+
|
|
247
|
+
function fmtTime(ts) {
|
|
248
|
+
if (ts === null || ts === undefined) return '—';
|
|
249
|
+
var d = new Date(ts);
|
|
250
|
+
return d.toLocaleTimeString('en-US', { hour12: false });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fmtAge(ts) {
|
|
254
|
+
if (ts === null || ts === undefined) return '—';
|
|
255
|
+
var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
256
|
+
if (s < 1) return 'now';
|
|
257
|
+
if (s < 60) return s + 's ago';
|
|
258
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
259
|
+
return Math.floor(s / 3600) + 'h ago';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function text(s) { return (s === null || s === undefined) ? '' : String(s); }
|
|
263
|
+
|
|
264
|
+
function escape(s) {
|
|
265
|
+
return text(s)
|
|
266
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
267
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function row(label, value, cls) {
|
|
271
|
+
return '<div class="row"><div class="label">' + escape(label) + '</div>' +
|
|
272
|
+
'<div class="value' + (cls ? ' ' + cls : '') + '">' + value + '</div></div>';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function badge(status) {
|
|
276
|
+
return '<span class="badge ' + escape(status) + '">' + escape(status) + '</span>';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function fetchJson(path) {
|
|
280
|
+
return fetch(path, { headers: headers, cache: 'no-store' })
|
|
281
|
+
.then(function (r) {
|
|
282
|
+
if (!r.ok) {
|
|
283
|
+
var err = new Error('HTTP ' + r.status + ' on ' + path);
|
|
284
|
+
err.status = r.status;
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
return r.json();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderRuntime(data) {
|
|
292
|
+
if (!data || data.present === false) {
|
|
293
|
+
el.runtime.innerHTML = '<div class="row"><div class="label">agent</div>' +
|
|
294
|
+
'<div class="value">' + badge('absent') + '</div></div>' +
|
|
295
|
+
'<div class="row"><div class="label">note</div>' +
|
|
296
|
+
'<div class="value dim">Start with <code>ggui dev --agent <entry></code> to supervise a local agent.</div></div>';
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
var capsParts = [];
|
|
300
|
+
if (data.capabilities) {
|
|
301
|
+
if (data.capabilities.observable) capsParts.push('observable');
|
|
302
|
+
if (data.capabilities.restartable) capsParts.push('restartable');
|
|
303
|
+
}
|
|
304
|
+
el.runtime.innerHTML =
|
|
305
|
+
row('status', badge(data.status || 'absent')) +
|
|
306
|
+
row('adapter', escape(data.name || '—')) +
|
|
307
|
+
row('run id', '<code>' + escape(data.runId || '—') + '</code>') +
|
|
308
|
+
row('capabilities', escape(capsParts.join(', ') || '—'), 'dim') +
|
|
309
|
+
row('started', escape(fmtTime(data.startedAt) + ' (' + fmtAge(data.startedAt) + ')'), 'dim') +
|
|
310
|
+
row('last event', escape(fmtTime(data.lastEventAt) + ' (' + fmtAge(data.lastEventAt) + ')'), 'dim');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderRegistry(data) {
|
|
314
|
+
if (!data) {
|
|
315
|
+
el.registry.innerHTML = '<div class="placeholder">—</div>';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
var issueBadge = data.issueCount > 0 ? ' ' + badge('warn') : '';
|
|
319
|
+
el.registry.innerHTML =
|
|
320
|
+
row('project', escape((data.app && data.app.name) || '—')) +
|
|
321
|
+
row('slug', escape((data.app && data.app.slug) || '—'), 'dim') +
|
|
322
|
+
row('protocol', escape(data.protocol || '—'), 'dim') +
|
|
323
|
+
row('UIs', escape(String(data.uiCount !== undefined ? data.uiCount : '—'))) +
|
|
324
|
+
row('issues', escape(String(data.issueCount !== undefined ? data.issueCount : '—')) + issueBadge);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderUis(list) {
|
|
328
|
+
lastUis = Array.isArray(list) ? list : [];
|
|
329
|
+
if (lastUis.length === 0) {
|
|
330
|
+
el.uis.innerHTML = '<div class="placeholder">No UIs discovered. Add <code>ggui.ui.json</code> files under <code>blueprints.include</code>.</div>';
|
|
331
|
+
if (el.uisCount) el.uisCount.textContent = '(0)';
|
|
332
|
+
// A removed selection should paint as a dangling state below.
|
|
333
|
+
renderPreview();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (el.uisCount) el.uisCount.textContent = '(' + lastUis.length + ')';
|
|
337
|
+
var html = '<ul class="uis">';
|
|
338
|
+
for (var i = 0; i < lastUis.length; i++) {
|
|
339
|
+
var entry = lastUis[i];
|
|
340
|
+
var id = (entry && entry.id) || '—';
|
|
341
|
+
var name = (entry && entry.manifest && entry.manifest.name) || '';
|
|
342
|
+
var pressed = id === selectedUi ? 'true' : 'false';
|
|
343
|
+
html += '<li><button type="button" class="ui-row" data-ui-id="' + escape(id) +
|
|
344
|
+
'" aria-pressed="' + pressed + '">' +
|
|
345
|
+
'<span class="ui-id">' + escape(id) + '</span>';
|
|
346
|
+
if (name) html += '<span class="ui-name">' + escape(name) + '</span>';
|
|
347
|
+
html += '<span class="ui-hint">' + (pressed === 'true' ? 'previewing' : 'preview →') + '</span>';
|
|
348
|
+
html += '</button></li>';
|
|
349
|
+
}
|
|
350
|
+
html += '</ul>';
|
|
351
|
+
el.uis.innerHTML = html;
|
|
352
|
+
var rows = el.uis.querySelectorAll('button.ui-row');
|
|
353
|
+
rows.forEach(function (row) {
|
|
354
|
+
row.addEventListener('click', function () {
|
|
355
|
+
var id = row.getAttribute('data-ui-id') || '';
|
|
356
|
+
setSelectedUi(id === selectedUi ? '' : id);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
renderPreview();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Render the Preview panel based on the current selectedUi.
|
|
363
|
+
// When nothing is selected we show a hint; otherwise we mount
|
|
364
|
+
// an iframe at /hub/preview?ui=ID — the shell over there is
|
|
365
|
+
// responsible for fetching + rendering the bundle. The iframe
|
|
366
|
+
// is replaced (not reused) across selections so the bundle
|
|
367
|
+
// inside gets a fresh boot every time.
|
|
368
|
+
function renderPreview() {
|
|
369
|
+
if (!el.preview) return;
|
|
370
|
+
if (!selectedUi) {
|
|
371
|
+
el.preview.innerHTML = '<div class="placeholder">Click a UI above to preview it here.</div>';
|
|
372
|
+
if (el.previewLabel) el.previewLabel.textContent = '';
|
|
373
|
+
if (el.previewClear) el.previewClear.hidden = true;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (el.previewLabel) el.previewLabel.textContent = selectedUi;
|
|
377
|
+
if (el.previewClear) el.previewClear.hidden = false;
|
|
378
|
+
var src = '/hub/preview?ui=' + encodeURIComponent(selectedUi);
|
|
379
|
+
// Replace the iframe wholesale rather than updating src —
|
|
380
|
+
// otherwise a stale renderer (e.g. mid-fetch) could paint over
|
|
381
|
+
// the fresh selection after we've moved on.
|
|
382
|
+
el.preview.innerHTML = '';
|
|
383
|
+
var frame = document.createElement('iframe');
|
|
384
|
+
frame.className = 'preview-frame';
|
|
385
|
+
frame.setAttribute('title', 'Preview of ' + selectedUi);
|
|
386
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
|
|
387
|
+
frame.setAttribute('src', src);
|
|
388
|
+
el.preview.appendChild(frame);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Persist selection in the URL + refresh affected panels. */
|
|
392
|
+
function setSelectedUi(id) {
|
|
393
|
+
if (id === selectedUi) return;
|
|
394
|
+
selectedUi = id;
|
|
395
|
+
writeSelectedToUrl(id);
|
|
396
|
+
// Re-render the UIs list to flip the pressed-state + hints,
|
|
397
|
+
// then bring the preview into (or out of) view.
|
|
398
|
+
if (lastUis.length > 0) renderUis(lastUis);
|
|
399
|
+
else renderPreview();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Narrow accepted characters so a malformed URL can't smuggle
|
|
403
|
+
* weird values into the preview iframe src. The iframe page
|
|
404
|
+
* applies the same filter server-side; this one is purely a
|
|
405
|
+
* first-pass sanity check. */
|
|
406
|
+
function isValidId(value) {
|
|
407
|
+
if (typeof value !== 'string') return false;
|
|
408
|
+
if (value.length === 0 || value.length > 200) return false;
|
|
409
|
+
return /^[a-zA-Z0-9._\\-:@/]+$/.test(value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readSelectedFromUrl() {
|
|
413
|
+
try {
|
|
414
|
+
var url = new URL(window.location.href);
|
|
415
|
+
var raw = (url.searchParams.get('ui') || '').trim();
|
|
416
|
+
return isValidId(raw) ? raw : '';
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function writeSelectedToUrl(id) {
|
|
423
|
+
try {
|
|
424
|
+
var url = new URL(window.location.href);
|
|
425
|
+
if (id) url.searchParams.set('ui', id);
|
|
426
|
+
else url.searchParams.delete('ui');
|
|
427
|
+
window.history.replaceState(null, '', url.toString());
|
|
428
|
+
} catch (e) {
|
|
429
|
+
// Swallow — persistence across reloads is a nice-to-have,
|
|
430
|
+
// not load-bearing for the current-session selection.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderEvents(data) {
|
|
435
|
+
if (!data || data.present === false) {
|
|
436
|
+
el.events.innerHTML = '<div class="placeholder">No runtime attached.</div>';
|
|
437
|
+
if (el.eventsCount) el.eventsCount.textContent = '';
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
var events = Array.isArray(data.events) ? data.events : [];
|
|
441
|
+
if (events.length === 0) {
|
|
442
|
+
el.events.innerHTML = '<div class="placeholder">No events yet.</div>';
|
|
443
|
+
if (el.eventsCount) el.eventsCount.textContent = '(0)';
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (el.eventsCount) el.eventsCount.textContent = '(' + events.length + ')';
|
|
447
|
+
// Render newest-first — operators scan top-down.
|
|
448
|
+
var html = '';
|
|
449
|
+
for (var i = events.length - 1; i >= Math.max(0, events.length - MAX_EVENTS); i--) {
|
|
450
|
+
var e = events[i];
|
|
451
|
+
var kindClass = 'k-' + e.type + (e.type === 'log' ? ('-' + (e.stream || '')) : '');
|
|
452
|
+
var kindLabel = e.type === 'log'
|
|
453
|
+
? ((e.stream || 'log'))
|
|
454
|
+
: (e.type === 'status' ? ('status:' + (e.status || '')) : 'error');
|
|
455
|
+
var body = e.type === 'log'
|
|
456
|
+
? (e.line || '')
|
|
457
|
+
: e.type === 'status' ? (e.status || '')
|
|
458
|
+
: (e.message || '');
|
|
459
|
+
html += '<div class="ev">'
|
|
460
|
+
+ '<div class="t">' + escape(fmtTime(e.timestamp)) + '</div>'
|
|
461
|
+
+ '<div class="' + kindClass + '">' + escape(kindLabel) + '</div>'
|
|
462
|
+
+ '<div class="msg">' + escape(body) + '</div>'
|
|
463
|
+
+ '</div>';
|
|
464
|
+
}
|
|
465
|
+
el.events.innerHTML = html;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function setTick(state, message) {
|
|
469
|
+
if (!el.tick) return;
|
|
470
|
+
el.tick.className = 'tick ' + state;
|
|
471
|
+
el.tick.textContent = message;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function setLive(state, message) {
|
|
475
|
+
if (!el.live) return;
|
|
476
|
+
el.live.className = 'live ' + state;
|
|
477
|
+
el.live.textContent = message;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function tick() {
|
|
481
|
+
try {
|
|
482
|
+
var results = await Promise.all([
|
|
483
|
+
fetchJson('/health').catch(function (e) { return { __err: e }; }),
|
|
484
|
+
fetchJson('/uis').catch(function (e) { return { __err: e }; }),
|
|
485
|
+
fetchJson('/runtime/status').catch(function (e) { return { __err: e }; }),
|
|
486
|
+
fetchJson('/runtime/events').catch(function (e) { return { __err: e }; }),
|
|
487
|
+
]);
|
|
488
|
+
|
|
489
|
+
var anyErr = results.find(function (r) { return r && r.__err; });
|
|
490
|
+
if (anyErr) {
|
|
491
|
+
var err = anyErr.__err;
|
|
492
|
+
if (err && err.status === 401) {
|
|
493
|
+
setTick('err', 'auth failed — token may be stale');
|
|
494
|
+
} else if (err && err.status === 403) {
|
|
495
|
+
setTick('err', 'origin rejected');
|
|
496
|
+
} else {
|
|
497
|
+
setTick('err', 'offline — retry in ' + Math.round(POLL_MS / 1000) + 's');
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
renderRegistry(results[0]);
|
|
503
|
+
renderUis(results[1]);
|
|
504
|
+
renderRuntime(results[2]);
|
|
505
|
+
renderEvents(results[3]);
|
|
506
|
+
setTick('ok', 'updated ' + fmtTime(Date.now()));
|
|
507
|
+
} catch (e) {
|
|
508
|
+
setTick('err', 'unexpected error — see console');
|
|
509
|
+
// eslint-disable-next-line no-console
|
|
510
|
+
console.error('[ggui-dev-hub]', e);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Refresh only the registry-side panels. Called after every
|
|
516
|
+
* 'event: ui' SSE frame so UI edits feel instant — the
|
|
517
|
+
* runtime poll is separate and keeps ticking at POLL_MS.
|
|
518
|
+
*/
|
|
519
|
+
async function refreshRegistry() {
|
|
520
|
+
try {
|
|
521
|
+
var results = await Promise.all([
|
|
522
|
+
fetchJson('/health').catch(function (e) { return { __err: e }; }),
|
|
523
|
+
fetchJson('/uis').catch(function (e) { return { __err: e }; }),
|
|
524
|
+
]);
|
|
525
|
+
if (results[0] && !results[0].__err) renderRegistry(results[0]);
|
|
526
|
+
if (results[1] && !results[1].__err) renderUis(results[1]);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
// eslint-disable-next-line no-console
|
|
529
|
+
console.error('[ggui-dev-hub] refreshRegistry', e);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Minimal SSE parser — same shape the dev server emits and the
|
|
535
|
+
* Studio local source consumes. Normalises CRLF, splits on the
|
|
536
|
+
* blank-line delimiter, ignores ':keep-alive' comment frames,
|
|
537
|
+
* and only dispatches 'event: ui' with a valid JSON body.
|
|
538
|
+
*/
|
|
539
|
+
async function readSse(reader, onUiEvent, onLive) {
|
|
540
|
+
var decoder = new TextDecoder();
|
|
541
|
+
var buffer = '';
|
|
542
|
+
var sawFirstFrame = false;
|
|
543
|
+
while (true) {
|
|
544
|
+
var chunk = await reader.read();
|
|
545
|
+
if (chunk.done) return;
|
|
546
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
547
|
+
buffer = buffer.replace(/\\r\\n/g, '\\n');
|
|
548
|
+
var sep;
|
|
549
|
+
while ((sep = buffer.indexOf('\\n\\n')) !== -1) {
|
|
550
|
+
var frame = buffer.slice(0, sep);
|
|
551
|
+
buffer = buffer.slice(sep + 2);
|
|
552
|
+
if (!sawFirstFrame) { sawFirstFrame = true; onLive(); }
|
|
553
|
+
if (frame.length === 0 || frame.startsWith(':')) continue;
|
|
554
|
+
var evt = '';
|
|
555
|
+
var data = '';
|
|
556
|
+
var lines = frame.split('\\n');
|
|
557
|
+
for (var i = 0; i < lines.length; i++) {
|
|
558
|
+
var line = lines[i];
|
|
559
|
+
if (line.indexOf('event:') === 0) evt = line.slice(6).trim();
|
|
560
|
+
else if (line.indexOf('data:') === 0) data += line.slice(5).trim();
|
|
561
|
+
}
|
|
562
|
+
if (evt !== 'ui' || !data) continue;
|
|
563
|
+
try {
|
|
564
|
+
var payload = JSON.parse(data);
|
|
565
|
+
onUiEvent(payload);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
// malformed frame — skip
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Keep an SSE stream to '/events' open for the life of the hub.
|
|
575
|
+
* Reconnects with a short backoff on drop / failure.
|
|
576
|
+
*/
|
|
577
|
+
async function subscribeRegistry() {
|
|
578
|
+
var backoff = 500;
|
|
579
|
+
setLive('reconnecting', 'connecting');
|
|
580
|
+
while (true) {
|
|
581
|
+
try {
|
|
582
|
+
var res = await fetch('/events', {
|
|
583
|
+
headers: headers,
|
|
584
|
+
cache: 'no-store',
|
|
585
|
+
});
|
|
586
|
+
if (!res.ok) {
|
|
587
|
+
setLive('offline', res.status === 401 ? 'auth failed' : 'offline');
|
|
588
|
+
await sleep(Math.min(backoff, 5000));
|
|
589
|
+
backoff = Math.min(backoff * 2, 5000);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
backoff = 500;
|
|
593
|
+
var reader = res.body.getReader();
|
|
594
|
+
await readSse(
|
|
595
|
+
reader,
|
|
596
|
+
function (evt) {
|
|
597
|
+
// Any UI registry event — added / changed / removed —
|
|
598
|
+
// means registry-side panels need to refresh. Debouncing
|
|
599
|
+
// is unnecessary: edits coalesce on chokidar's
|
|
600
|
+
// awaitWriteFinish already.
|
|
601
|
+
void refreshRegistry();
|
|
602
|
+
},
|
|
603
|
+
function () { setLive('live', 'live'); },
|
|
604
|
+
);
|
|
605
|
+
// Stream ended cleanly — reconnect.
|
|
606
|
+
setLive('reconnecting', 'reconnecting');
|
|
607
|
+
} catch (e) {
|
|
608
|
+
setLive('reconnecting', 'reconnecting');
|
|
609
|
+
await sleep(Math.min(backoff, 5000));
|
|
610
|
+
backoff = Math.min(backoff * 2, 5000);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function sleep(ms) {
|
|
616
|
+
return new Promise(function (r) { setTimeout(r, ms); });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Render the Diagnostics panel — one row per endpoint with a
|
|
621
|
+
* click-to-copy curl command. Done once on load (host/port/token
|
|
622
|
+
* don't change for the life of the tab).
|
|
623
|
+
*/
|
|
624
|
+
function renderDiagnostics() {
|
|
625
|
+
if (!el.diagnostics) return;
|
|
626
|
+
var origin = window.location.origin;
|
|
627
|
+
var bearer = token ? (' -H "Authorization: Bearer ' + token + '"') : '';
|
|
628
|
+
var endpoints = [
|
|
629
|
+
['health', 'GET ' + origin + '/health'],
|
|
630
|
+
['hub', 'GET ' + origin + '/hub'],
|
|
631
|
+
['preview', 'GET ' + origin + '/hub/preview?ui=<id>'],
|
|
632
|
+
['uis', 'curl -s' + bearer + ' ' + origin + '/uis'],
|
|
633
|
+
['bundle', 'curl -s' + bearer + ' ' + origin + '/uis/<id>/bundle'],
|
|
634
|
+
['events (sse)', 'curl -sN' + bearer + ' ' + origin + '/events'],
|
|
635
|
+
['runtime status', 'curl -s' + bearer + ' ' + origin + '/runtime/status'],
|
|
636
|
+
['runtime events', 'curl -s' + bearer + ' ' + origin + '/runtime/events'],
|
|
637
|
+
];
|
|
638
|
+
if (token) {
|
|
639
|
+
endpoints.push(['token', token]);
|
|
640
|
+
}
|
|
641
|
+
var html = '';
|
|
642
|
+
for (var i = 0; i < endpoints.length; i++) {
|
|
643
|
+
var label = endpoints[i][0];
|
|
644
|
+
var cmd = endpoints[i][1];
|
|
645
|
+
html += '<div class="diag-row" data-cmd="' + escape(cmd) + '">' +
|
|
646
|
+
'<div class="diag-label">' + escape(label) + '</div>' +
|
|
647
|
+
'<div class="diag-cmd">' + escape(cmd) + '</div>' +
|
|
648
|
+
'</div>';
|
|
649
|
+
}
|
|
650
|
+
el.diagnostics.innerHTML = html;
|
|
651
|
+
var rows = el.diagnostics.querySelectorAll('.diag-row');
|
|
652
|
+
rows.forEach(function (row) {
|
|
653
|
+
row.addEventListener('click', function () {
|
|
654
|
+
var text = row.getAttribute('data-cmd') || '';
|
|
655
|
+
copyToClipboard(text, row);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function copyToClipboard(text, row) {
|
|
661
|
+
var done = function () {
|
|
662
|
+
row.classList.add('copied');
|
|
663
|
+
setTimeout(function () { row.classList.remove('copied'); }, 900);
|
|
664
|
+
};
|
|
665
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
666
|
+
navigator.clipboard.writeText(text).then(done).catch(function () {
|
|
667
|
+
fallbackCopy(text); done();
|
|
668
|
+
});
|
|
669
|
+
} else {
|
|
670
|
+
fallbackCopy(text); done();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function fallbackCopy(text) {
|
|
675
|
+
var ta = document.createElement('textarea');
|
|
676
|
+
ta.value = text;
|
|
677
|
+
ta.setAttribute('readonly', '');
|
|
678
|
+
ta.style.position = 'absolute';
|
|
679
|
+
ta.style.left = '-9999px';
|
|
680
|
+
document.body.appendChild(ta);
|
|
681
|
+
ta.select();
|
|
682
|
+
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
|
683
|
+
document.body.removeChild(ta);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Keyboard refresh: press 'r' anywhere not inside an input
|
|
688
|
+
* to force an immediate tick + registry refresh. Cheap but
|
|
689
|
+
* meaningfully faster than waiting for the 2s poll.
|
|
690
|
+
*/
|
|
691
|
+
function installKeyboardShortcuts() {
|
|
692
|
+
document.addEventListener('keydown', function (e) {
|
|
693
|
+
if (e.defaultPrevented) return;
|
|
694
|
+
var tag = (e.target && e.target.tagName) || '';
|
|
695
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
696
|
+
if (e.key === 'r' || e.key === 'R') {
|
|
697
|
+
void tick();
|
|
698
|
+
void refreshRegistry();
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Attach the Clear button's handler once the elements exist. */
|
|
704
|
+
function installPreviewControls() {
|
|
705
|
+
if (!el.previewClear) return;
|
|
706
|
+
el.previewClear.addEventListener('click', function () {
|
|
707
|
+
setSelectedUi('');
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
tick();
|
|
712
|
+
setInterval(tick, POLL_MS);
|
|
713
|
+
renderDiagnostics();
|
|
714
|
+
installKeyboardShortcuts();
|
|
715
|
+
installPreviewControls();
|
|
716
|
+
// Paint an initial preview based on the URL param so a reload /
|
|
717
|
+
// linked URL lands in the same state it came from. The real UI
|
|
718
|
+
// list renders shortly after tick() resolves; until then the
|
|
719
|
+
// preview is already showing the right frame.
|
|
720
|
+
renderPreview();
|
|
721
|
+
void subscribeRegistry();
|
|
722
|
+
})();
|
|
723
|
+
`;
|