impreza-mcp 0.6.0 → 0.7.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/dist/ui-assets.js CHANGED
@@ -10,2467 +10,2467 @@
10
10
  // this copy against the canonical HTML byte for byte, so editing it here would
11
11
  // fail the drift gate rather than quietly diverge — and this page shows
12
12
  // payment addresses, which is not a page to keep two versions of.
13
- export const TOPUP_CARD_HTML = `<!DOCTYPE html>
14
- <html lang="en">
15
- <head>
16
- <meta charset="utf-8">
17
-
18
- <title>Top-up payment</title>
19
- <!--
20
- Impreza Host — MCP Apps view for impreza_topup_payment.
21
-
22
- Why this exists: a crypto address is the one value in this product that plain
23
- chat text handles badly. Text gets re-wrapped, truncated, summarised, and — in
24
- the prompt-injection case — rewritten by a model that read a hostile page. Here
25
- the address arrives in \`structuredContent\`, which never passes through the
26
- model's output, and is rendered through \`textContent\`, never \`innerHTML\`.
27
-
28
- The shared rules (no network, no innerHTML, never inject the host's font CSS)
29
- live in _bridge.js and are enforced by tools/verify_mcp_apps.php.
30
- -->
31
- <style>
32
- /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
33
- *
34
- * Substituted into each panel's TOKENS marker by McpUi::read() and by
35
- * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
36
- * nothing from the network, so the choice was one substitution or three copies.
37
- * Keeping it in one file is also what makes our panels read as one family
38
- * rather than three pages that happen to ship together.
39
- *
40
- * Host-provided variables win. These fallbacks are what a panel looks like when
41
- * a host sends none — which the spec warns is common, along with partial sets.
42
- * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
43
- */
44
-
45
- :root {
46
- color-scheme: light dark;
47
-
48
- --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
49
- --color-background-secondary: light-dark(#f4f3ef, #232320);
50
- --color-background-tertiary: light-dark(#eceae4, #2b2a26);
51
- --color-text-primary: light-dark(#1a1a17, #f2f1ed);
52
- --color-text-secondary: light-dark(#6e6d66, #a5a49c);
53
- --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
54
- --color-text-warning: light-dark(#8a5a00, #e0a83a);
55
- --color-text-success: light-dark(#1f6b3a, #5fbf83);
56
- --color-text-danger: light-dark(#9a2b21, #f08a7e);
57
- --color-border-primary: light-dark(#e5e4de, #33302b);
58
- --color-border-secondary: light-dark(#efeee9, #2a2825);
59
-
60
- --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
61
- --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
62
-
63
- --border-radius-sm: 6px;
64
- --border-radius-md: 10px;
65
- --border-radius-full: 999px;
66
- }
67
-
68
- * { box-sizing: border-box; }
69
-
70
- body {
71
- margin: 0;
72
- background: var(--color-background-primary);
73
- color: var(--color-text-primary);
74
- font-family: var(--font-sans);
75
- font-size: 14px;
76
- line-height: 1.45;
77
- -webkit-font-smoothing: antialiased;
78
- }
79
-
80
- .card {
81
- display: flex;
82
- flex-direction: column;
83
- gap: 14px;
84
- padding: 16px;
85
- max-width: 560px;
86
- }
87
-
88
- /* ── header ───────────────────────────────────────────────────────────── */
89
- .head {
90
- display: flex;
91
- align-items: flex-start;
92
- justify-content: space-between;
93
- gap: 12px;
94
- }
95
- .title {
96
- font-family: var(--font-mono);
97
- font-size: 18px;
98
- letter-spacing: -0.01em;
99
- overflow-wrap: anywhere;
100
- }
101
- .total {
102
- font-family: var(--font-mono);
103
- font-size: 22px;
104
- font-variant-numeric: tabular-nums;
105
- letter-spacing: -0.01em;
106
- }
107
- .sub {
108
- margin-top: 2px;
109
- color: var(--color-text-secondary);
110
- font-size: 12px;
111
- }
112
-
113
- /* State, and state only — the one place colour is allowed to catch the eye. */
114
- .chip {
115
- flex: none;
116
- padding: 3px 10px;
117
- border: 1px solid var(--color-border-primary);
118
- border-radius: var(--border-radius-full);
119
- font-size: 11px;
120
- font-weight: 600;
121
- letter-spacing: 0.04em;
122
- text-transform: uppercase;
123
- white-space: nowrap;
124
- }
125
- .chip[data-state="pending"] { color: var(--color-text-warning); }
126
- .chip[data-state="paid"],
127
- .chip[data-state="ok"] { color: var(--color-text-success); }
128
- .chip[data-state="error"],
129
- .chip[data-state="off"] { color: var(--color-text-danger); }
130
- .chip[data-state="idle"] { color: var(--color-text-secondary); }
131
-
132
- /* ── labelled value blocks ────────────────────────────────────────────── */
133
- .field {
134
- border: 1px solid var(--color-border-primary);
135
- border-radius: var(--border-radius-md);
136
- background: var(--color-background-secondary);
137
- padding: 10px 12px;
138
- }
139
- .field-head {
140
- display: flex;
141
- align-items: baseline;
142
- justify-content: space-between;
143
- gap: 8px;
144
- margin-bottom: 6px;
145
- }
146
- .label {
147
- color: var(--color-text-tertiary);
148
- font-size: 10px;
149
- font-weight: 700;
150
- letter-spacing: 0.08em;
151
- text-transform: uppercase;
152
- }
153
- .value {
154
- font-family: var(--font-mono);
155
- font-size: 15px;
156
- font-variant-numeric: tabular-nums;
157
- overflow-wrap: anywhere;
158
- user-select: all;
159
- }
160
-
161
- /* ── controls ─────────────────────────────────────────────────────────── */
162
- .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
163
- .btn {
164
- padding: 6px 13px;
165
- border: 1px solid var(--color-border-primary);
166
- border-radius: var(--border-radius-sm);
167
- background: var(--color-background-tertiary);
168
- color: var(--color-text-primary);
169
- font: inherit;
170
- font-size: 12px;
171
- font-weight: 600;
172
- cursor: pointer;
173
- }
174
- .btn:disabled { opacity: 0.5; cursor: default; }
175
- .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
176
- /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
177
- .btn[data-armed="1"] {
178
- color: var(--color-text-danger);
179
- border-color: var(--color-text-danger);
180
- }
181
- .btn.quiet {
182
- background: transparent;
183
- color: var(--color-text-secondary);
184
- }
185
- .btn.quiet:hover { color: var(--color-text-primary); }
186
- .copy {
187
- flex: none;
188
- padding: 3px 9px;
189
- border: 1px solid var(--color-border-primary);
190
- border-radius: var(--border-radius-sm);
191
- background: var(--color-background-primary);
192
- color: var(--color-text-secondary);
193
- font: inherit;
194
- font-size: 11px;
195
- font-weight: 600;
196
- cursor: pointer;
197
- }
198
- .copy:hover { color: var(--color-text-primary); }
199
- .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
200
-
201
- input[type="text"], select {
202
- width: 100%;
203
- padding: 6px 9px;
204
- border: 1px solid var(--color-border-primary);
205
- border-radius: var(--border-radius-sm);
206
- background: var(--color-background-primary);
207
- color: var(--color-text-primary);
208
- font: inherit;
209
- font-size: 13px;
210
- }
211
- input[type="text"] { font-family: var(--font-mono); }
212
- input:focus-visible, select:focus-visible {
213
- outline: 2px solid var(--color-text-secondary);
214
- outline-offset: 1px;
215
- }
216
- .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
217
- .check input { margin: 0; }
218
-
219
- /* ── notes ────────────────────────────────────────────────────────────── */
220
- .note { color: var(--color-text-secondary); font-size: 11.5px; }
221
- .note strong { color: var(--color-text-primary); font-weight: 600; }
222
- .warn { color: var(--color-text-danger); }
223
- .rest { color: var(--color-text-secondary); font-size: 13px; }
224
-
225
- [hidden] { display: none !important; }
226
-
227
- @media (prefers-reduced-motion: no-preference) {
228
- .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
229
- }
230
-
231
-
232
- /* An address is checked at its ENDS. Grouping it and dimming the middle is
233
- what makes "did I paste the right one?" answerable at a glance. */
234
- .addr { display: flex; flex-wrap: wrap; gap: 0 7px; line-height: 1.6; }
235
- .g { color: var(--color-text-secondary); }
236
- .g.edge { color: var(--color-text-primary); font-weight: 600; }
237
-
238
- /* Only rendered when there is a real choice of rail. */
239
- .rails { display: flex; flex-wrap: wrap; gap: 6px; }
240
- .rail {
241
- padding: 5px 11px;
242
- border: 1px solid var(--color-border-primary);
243
- border-radius: var(--border-radius-sm);
244
- background: transparent;
245
- color: var(--color-text-secondary);
246
- font: inherit;
247
- font-size: 12px;
248
- font-weight: 600;
249
- cursor: pointer;
250
- }
251
- .rail[aria-selected="true"] {
252
- background: var(--color-background-tertiary);
253
- color: var(--color-text-primary);
254
- }
255
- .rail:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
256
- </style>
257
- </head>
258
- <body>
259
- <div class="card">
260
- <div class="head">
261
- <div>
262
- <div class="total" id="total">—</div>
263
- <div class="sub" id="sub">Waiting for payment details…</div>
264
- </div>
265
- <div class="chip" id="chip" data-state="pending">Pending</div>
266
- </div>
267
-
268
- <div class="rails" id="rails" hidden role="tablist" aria-label="Payment currency"></div>
269
-
270
- <div id="pay" hidden>
271
- <div class="field" style="margin-bottom:10px">
272
- <div class="field-head">
273
- <span class="label">Send exactly</span>
274
- <button class="copy" id="copyAmount" type="button">Copy</button>
275
- </div>
276
- <div class="value" id="amount"></div>
277
- <div class="sub" id="rate" hidden></div>
278
- </div>
279
-
280
- <!-- Address last, closest to the action: a wrong amount can be topped up,
281
- a wrong address cannot be recovered. -->
282
- <div class="field">
283
- <div class="field-head">
284
- <span class="label" id="addrLabel">To this address</span>
285
- <button class="copy" id="copyAddr" type="button">Copy</button>
286
- </div>
287
- <div class="value addr" id="addr"></div>
288
- </div>
289
- </div>
290
-
291
- <div class="rest" id="rest" hidden></div>
292
-
293
- <div class="actions">
294
- <button class="btn" id="check" type="button" hidden>Check payment</button>
295
- <button class="btn" id="wallet" type="button" hidden>Open in wallet</button>
296
- <span class="note" id="status"></span>
297
- </div>
298
-
299
- <div class="note" id="verify" hidden>
300
- Check the <strong>highlighted first and last groups</strong> of the address against
301
- your wallet before sending. Send the exact amount — a short payment credits short.
302
- </div>
303
- </div>
304
-
305
- <script>
306
- // Impreza Host — the MCP Apps host bridge, shared by every panel.
307
- //
308
- // Substituted into each panel's BRIDGE marker by McpUi::read() and by
309
- // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
310
- // nothing from the network, so the only alternatives to substitution were three
311
- // copies of this file or a build step. One mechanism, one place the verifier has
312
- // to lint, and the panels stay readable.
313
- //
314
- // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
315
- // · No network. Every value arrives over this bridge.
316
- // · Never innerHTML. Tool values reach the DOM through textContent only.
317
- // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
318
- // an @import from a font service and its own applyHostFonts() injects it
319
- // verbatim, which on our panels is a third-party request identifying the
320
- // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
321
-
322
- 'use strict';
323
-
324
- var Bridge = (function () {
325
- var nextId = 1;
326
- var pending = Object.create(null);
327
- var handlers = Object.create(null);
328
- var hostCaps = {};
329
-
330
- window.addEventListener('message', function (event) {
331
- var msg = event.data;
332
- if (!msg || msg.jsonrpc !== '2.0') { return; }
333
- if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
334
- var p = pending[msg.id];
335
- delete pending[msg.id];
336
- if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
337
- else { p.resolve(msg.result); }
338
- return;
339
- }
340
- if (msg.method && handlers[msg.method]) {
341
- // A malformed notification must not take the panel down with it.
342
- try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
343
- }
344
- });
345
-
346
- function request(method, params) {
347
- var id = nextId++;
348
- return new Promise(function (resolve, reject) {
349
- pending[id] = { resolve: resolve, reject: reject };
350
- window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
351
- });
352
- }
353
-
354
- function notify(method, params) {
355
- window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
356
- }
357
-
358
- function on(method, fn) { handlers[method] = fn; }
359
-
360
- /**
361
- * Call one of OUR tools through the host. The host decides whether to
362
- * forward it and may ask the viewer first — a panel never assumes consent.
363
- * Resolves with the tool's structuredContent when it has one, falling back to
364
- * parsing the text block for a host that only forwards \`content\`.
365
- */
366
- function callTool(name, args) {
367
- return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
368
- res = res || {};
369
- if (res.isError) {
370
- var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
371
- throw new Error(text);
372
- }
373
- return unwrap(res);
374
- });
375
- }
376
-
377
- /** structuredContent if present, else the text block parsed as JSON. */
378
- function unwrap(payload) {
379
- if (!payload) { return null; }
380
- if (payload.structuredContent) { return payload.structuredContent; }
381
- var t = payload.content && payload.content[0] && payload.content[0].text;
382
- if (typeof t === 'string') {
383
- try { return JSON.parse(t); } catch (e) { return null; }
384
- }
385
- return null;
386
- }
387
-
388
- function applyStyles(styles) {
389
- if (!styles || !styles.variables) { return; }
390
- Object.keys(styles.variables).forEach(function (k) {
391
- var v = styles.variables[k];
392
- // Only custom properties, and only plain values: a variable is otherwise
393
- // a place a host could smuggle CSS into our document.
394
- if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
395
- document.documentElement.style.setProperty(k, v);
396
- }
397
- });
398
- // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
399
- }
400
-
401
- function applyTheme(theme) {
402
- if (theme === 'light' || theme === 'dark') {
403
- document.documentElement.style.colorScheme = theme;
404
- }
405
- }
406
-
407
- var lastH = 0;
408
- function reportSize() {
409
- var h = Math.ceil(document.documentElement.scrollHeight);
410
- var w = Math.ceil(document.documentElement.scrollWidth);
411
- if (h === lastH) { return; }
412
- lastH = h;
413
- notify('ui/notifications/size-changed', { width: w, height: h });
414
- }
415
-
416
- /**
417
- * Handshake, then hand the panel its data.
418
- *
419
- * onResult receives the tool result (unwrapped), onInput the tool arguments,
420
- * onFail a message when there is no bridge or the call was cancelled.
421
- */
422
- function start(opts) {
423
- opts = opts || {};
424
- var onResult = opts.onResult || function () {};
425
- var onInput = opts.onInput || function () {};
426
- var onFail = opts.onFail || function () {};
427
-
428
- on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
429
- on('ui/notifications/tool-result', function (params) {
430
- if (params && params.isError) {
431
- var text = (params.content && params.content[0] && params.content[0].text) || '';
432
- onFail(text || 'The tool call failed.');
433
- return;
434
- }
435
- var data = unwrap(params);
436
- if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
437
- reportSize();
438
- });
439
- on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
440
- on('ui/notifications/host-context-changed', function (params) {
441
- if (!params) { return; }
442
- if (params.theme) { applyTheme(params.theme); }
443
- if (params.styles) { applyStyles(params.styles); }
444
- });
445
-
446
- if (window.ResizeObserver) {
447
- new ResizeObserver(reportSize).observe(document.body);
448
- }
449
-
450
- return request('ui/initialize', {
451
- protocolVersion: '2026-01-26',
452
- clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
453
- appCapabilities: {}
454
- }).then(function (result) {
455
- result = result || {};
456
- hostCaps = result.capabilities || {};
457
- var ctx = result.hostContext || {};
458
- applyTheme(ctx.theme);
459
- applyStyles(ctx.styles);
460
- notify('ui/notifications/initialized', {});
461
- reportSize();
462
- return { capabilities: hostCaps, hostContext: ctx };
463
- }, function () {
464
- // Standalone, with no host on the other end. Saying so beats a spinner
465
- // that never resolves.
466
- onFail('This panel needs an MCP host to load its data.');
467
- throw new Error('no host bridge');
468
- });
469
- }
470
-
471
- /** Ask the host to open a URL. Only offered when it says it can. */
472
- function openLink(url) {
473
- return request('ui/open-link', { url: String(url) });
474
- }
475
-
476
- function canOpenLinks() { return !!hostCaps.openLinks; }
477
-
478
- /** Copy text, with a fallback because permissions are never guaranteed. */
479
- function copy(text, button, okLabel, failLabel) {
480
- var done = function (ok) {
481
- var original = button.dataset.label || button.textContent;
482
- button.dataset.label = original;
483
- button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
484
- setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
485
- };
486
- if (navigator.clipboard && navigator.clipboard.writeText) {
487
- navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
488
- return;
489
- }
490
- done(false);
491
- }
492
-
493
- return {
494
- request: request,
495
- notify: notify,
496
- on: on,
497
- callTool: callTool,
498
- unwrap: unwrap,
499
- start: start,
500
- reportSize: reportSize,
501
- openLink: openLink,
502
- canOpenLinks: canOpenLinks,
503
- copy: copy
504
- };
505
- })();
506
-
507
- /** Shorthand every panel uses. */
508
- function el(id) { return document.getElementById(id); }
509
-
510
-
511
- (function () {
512
- 'use strict';
513
-
514
- var options = [];
515
- var picked = 0;
516
- var invoiceId = null;
517
- var busy = false;
518
-
519
- function setChip(state, text) {
520
- var chip = el('chip');
521
- chip.dataset.state = state;
522
- chip.textContent = text;
523
- }
524
-
525
- function money(amount, currency) {
526
- var n = Number(amount);
527
- var shown = isFinite(n) ? n.toFixed(2) : String(amount == null ? '' : amount);
528
- return shown + ' ' + String(currency || '');
529
- }
530
-
531
- // Groups of four. The FIRST four and the LAST four characters are the anchors
532
- // a person actually checks against their wallet, so they are taken as the
533
- // edges before the middle is chunked — grouping left-to-right and marking the
534
- // final group would leave a two-character anchor on many addresses, which is
535
- // a weaker thing to ask someone to verify.
536
- function renderAddress(address) {
537
- var box = el('addr');
538
- box.textContent = '';
539
- var s = String(address || '');
540
- if (s === '') { return; }
541
-
542
- var parts;
543
- if (s.length <= 12) {
544
- parts = [{ t: s, edge: true }];
545
- } else {
546
- parts = [{ t: s.slice(0, 4), edge: true }];
547
- (s.slice(4, -4).match(/.{1,4}/g) || []).forEach(function (g) {
548
- parts.push({ t: g, edge: false });
549
- });
550
- parts.push({ t: s.slice(-4), edge: true });
551
- }
552
-
553
- parts.forEach(function (p) {
554
- var span = document.createElement('span');
555
- span.className = 'g' + (p.edge ? ' edge' : '');
556
- span.textContent = p.t;
557
- box.appendChild(span);
558
- });
559
- }
560
-
561
- function renderRails() {
562
- var box = el('rails');
563
- box.textContent = '';
564
- if (options.length < 2) { box.hidden = true; return; }
565
- box.hidden = false;
566
- options.forEach(function (o, i) {
567
- var b = document.createElement('button');
568
- b.type = 'button';
569
- b.className = 'rail';
570
- b.setAttribute('role', 'tab');
571
- b.setAttribute('aria-selected', i === picked ? 'true' : 'false');
572
- b.textContent = String(o.crypto || o.rail || '?');
573
- b.addEventListener('click', function () { picked = i; renderOption(); renderRails(); });
574
- box.appendChild(b);
575
- });
576
- }
577
-
578
- function renderOption() {
579
- var o = options[picked];
580
- if (!o) { el('pay').hidden = true; return; }
581
- el('pay').hidden = false;
582
- el('verify').hidden = false;
583
-
584
- el('amount').textContent = String(o.crypto_amount || '') + ' ' + String(o.crypto || '');
585
- el('addrLabel').textContent = o.network
586
- ? 'To this address · ' + String(o.network)
587
- : 'To this address';
588
- renderAddress(o.address);
589
-
590
- var rate = el('rate');
591
- var bits = [];
592
- if (o.rate) { bits.push('Rate ' + String(o.rate)); }
593
- if (o.expires_at) { bits.push('Expires ' + String(o.expires_at)); }
594
- if (o.note) { bits.push(String(o.note)); }
595
- rate.textContent = bits.join(' · ');
596
- rate.hidden = bits.length === 0;
597
-
598
- // A wallet hand-off is only offered when the host says it can open links.
599
- el('wallet').hidden = !(Bridge.canOpenLinks() && o.payment_uri);
600
- Bridge.reportSize();
601
- }
602
-
603
- function renderRest(available) {
604
- var box = el('rest');
605
- if (!available) { box.hidden = true; return; }
606
- // Whatever is already a tab is not "also payable in" — repeating it reads
607
- // as a second, worse route to the same thing.
608
- var shown = {};
609
- options.forEach(function (o) { shown[String(o.crypto || '').toUpperCase()] = true; });
610
- var more = [];
611
- var add = function (c) {
612
- var k = String(c).toUpperCase();
613
- if (!shown[k]) { shown[k] = true; more.push(k); }
614
- };
615
- (available.tronpay || []).forEach(add);
616
- (available.altcoins_via_fixedfloat || []).forEach(add);
617
- if (more.length === 0) { box.hidden = true; return; }
618
- box.hidden = false;
619
- box.textContent = 'Also payable in ' + more.slice(0, 12).join(', ')
620
- + (more.length > 12 ? ' and more' : '')
621
- + ' — ask for that coin to get its own address.';
622
- }
623
-
624
- function render(data) {
625
- if (!data || typeof data !== 'object') { return; }
626
- invoiceId = data.invoice_id != null ? data.invoice_id : invoiceId;
627
-
628
- el('total').textContent = money(data.fiat_amount, data.currency);
629
- el('sub').textContent = invoiceId != null ? ('Top-up invoice #' + String(invoiceId)) : '';
630
-
631
- var paid = String(data.status || '').toLowerCase() === 'paid';
632
- setChip(paid ? 'paid' : 'pending', paid ? 'Paid' : 'Awaiting payment');
633
-
634
- options = Array.isArray(data.payment_options) ? data.payment_options : [];
635
- if (picked >= options.length) { picked = 0; }
636
-
637
- if (paid) {
638
- el('pay').hidden = true;
639
- el('rails').hidden = true;
640
- el('verify').hidden = true;
641
- el('rest').hidden = true;
642
- el('wallet').hidden = true;
643
- el('status').textContent = data.balance != null
644
- ? ('Credited. Account balance is now ' + money(data.balance, data.currency) + '.')
645
- : 'Credited to the account balance.';
646
- } else {
647
- renderRails();
648
- renderOption();
649
- renderRest(data.available);
650
- }
651
-
652
- el('check').hidden = paid || invoiceId == null;
653
- Bridge.reportSize();
654
- }
655
-
656
- function fail(text) {
657
- setChip('error', 'Unavailable');
658
- el('sub').textContent = '';
659
- var s = el('status');
660
- s.className = 'note warn';
661
- s.textContent = String(text || 'Could not load the payment details.');
662
- Bridge.reportSize();
663
- }
664
-
665
- el('copyAmount').addEventListener('click', function () {
666
- var o = options[picked];
667
- if (o) { Bridge.copy(String(o.crypto_amount || ''), this); }
668
- });
669
- el('copyAddr').addEventListener('click', function () {
670
- var o = options[picked];
671
- if (o) { Bridge.copy(String(o.address || ''), this); }
672
- });
673
-
674
- el('wallet').addEventListener('click', function () {
675
- var o = options[picked];
676
- if (!o || !o.payment_uri) { return; }
677
- Bridge.openLink(o.payment_uri).catch(function () {
678
- el('status').textContent = 'The host declined to open the wallet link — copy the address instead.';
679
- });
680
- });
681
-
682
- // The one server call this panel makes, and the mildest one available: a
683
- // read-scope status poll on the caller's own invoice.
684
- el('check').addEventListener('click', function () {
685
- if (busy || invoiceId == null) { return; }
686
- busy = true;
687
- var btn = this;
688
- btn.disabled = true;
689
- el('status').className = 'note';
690
- el('status').textContent = 'Checking…';
691
- Bridge.callTool('impreza_topup_status', { invoice_id: invoiceId })
692
- .then(function (data) {
693
- if (data && String(data.status || '').toLowerCase() === 'paid') {
694
- render({
695
- invoice_id: invoiceId,
696
- fiat_amount: data.amount != null ? data.amount : data.fiat_amount,
697
- currency: data.currency,
698
- status: 'paid',
699
- balance: data.balance
700
- });
701
- } else {
702
- el('status').textContent = 'Not confirmed yet. On-chain confirmation usually takes a few minutes.';
703
- }
704
- })
705
- .catch(function (e) {
706
- el('status').className = 'note warn';
707
- el('status').textContent = e.message || 'The check failed. Try again in a moment.';
708
- })
709
- .then(function () {
710
- busy = false;
711
- btn.disabled = false;
712
- Bridge.reportSize();
713
- });
714
- });
715
-
716
- Bridge.start({ name: 'impreza-topup-card', onResult: render, onFail: fail });
717
- })();
718
- </script>
719
- </body>
720
- </html>
13
+ export const TOPUP_CARD_HTML = `<!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8">
17
+
18
+ <title>Top-up payment</title>
19
+ <!--
20
+ Impreza Host — MCP Apps view for impreza_topup_payment.
21
+
22
+ Why this exists: a crypto address is the one value in this product that plain
23
+ chat text handles badly. Text gets re-wrapped, truncated, summarised, and — in
24
+ the prompt-injection case — rewritten by a model that read a hostile page. Here
25
+ the address arrives in \`structuredContent\`, which never passes through the
26
+ model's output, and is rendered through \`textContent\`, never \`innerHTML\`.
27
+
28
+ The shared rules (no network, no innerHTML, never inject the host's font CSS)
29
+ live in _bridge.js and are enforced by tools/verify_mcp_apps.php.
30
+ -->
31
+ <style>
32
+ /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
33
+ *
34
+ * Substituted into each panel's TOKENS marker by McpUi::read() and by
35
+ * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
36
+ * nothing from the network, so the choice was one substitution or three copies.
37
+ * Keeping it in one file is also what makes our panels read as one family
38
+ * rather than three pages that happen to ship together.
39
+ *
40
+ * Host-provided variables win. These fallbacks are what a panel looks like when
41
+ * a host sends none — which the spec warns is common, along with partial sets.
42
+ * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
43
+ */
44
+
45
+ :root {
46
+ color-scheme: light dark;
47
+
48
+ --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
49
+ --color-background-secondary: light-dark(#f4f3ef, #232320);
50
+ --color-background-tertiary: light-dark(#eceae4, #2b2a26);
51
+ --color-text-primary: light-dark(#1a1a17, #f2f1ed);
52
+ --color-text-secondary: light-dark(#6e6d66, #a5a49c);
53
+ --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
54
+ --color-text-warning: light-dark(#8a5a00, #e0a83a);
55
+ --color-text-success: light-dark(#1f6b3a, #5fbf83);
56
+ --color-text-danger: light-dark(#9a2b21, #f08a7e);
57
+ --color-border-primary: light-dark(#e5e4de, #33302b);
58
+ --color-border-secondary: light-dark(#efeee9, #2a2825);
59
+
60
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
61
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
62
+
63
+ --border-radius-sm: 6px;
64
+ --border-radius-md: 10px;
65
+ --border-radius-full: 999px;
66
+ }
67
+
68
+ * { box-sizing: border-box; }
69
+
70
+ body {
71
+ margin: 0;
72
+ background: var(--color-background-primary);
73
+ color: var(--color-text-primary);
74
+ font-family: var(--font-sans);
75
+ font-size: 14px;
76
+ line-height: 1.45;
77
+ -webkit-font-smoothing: antialiased;
78
+ }
79
+
80
+ .card {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 14px;
84
+ padding: 16px;
85
+ max-width: 560px;
86
+ }
87
+
88
+ /* ── header ───────────────────────────────────────────────────────────── */
89
+ .head {
90
+ display: flex;
91
+ align-items: flex-start;
92
+ justify-content: space-between;
93
+ gap: 12px;
94
+ }
95
+ .title {
96
+ font-family: var(--font-mono);
97
+ font-size: 18px;
98
+ letter-spacing: -0.01em;
99
+ overflow-wrap: anywhere;
100
+ }
101
+ .total {
102
+ font-family: var(--font-mono);
103
+ font-size: 22px;
104
+ font-variant-numeric: tabular-nums;
105
+ letter-spacing: -0.01em;
106
+ }
107
+ .sub {
108
+ margin-top: 2px;
109
+ color: var(--color-text-secondary);
110
+ font-size: 12px;
111
+ }
112
+
113
+ /* State, and state only — the one place colour is allowed to catch the eye. */
114
+ .chip {
115
+ flex: none;
116
+ padding: 3px 10px;
117
+ border: 1px solid var(--color-border-primary);
118
+ border-radius: var(--border-radius-full);
119
+ font-size: 11px;
120
+ font-weight: 600;
121
+ letter-spacing: 0.04em;
122
+ text-transform: uppercase;
123
+ white-space: nowrap;
124
+ }
125
+ .chip[data-state="pending"] { color: var(--color-text-warning); }
126
+ .chip[data-state="paid"],
127
+ .chip[data-state="ok"] { color: var(--color-text-success); }
128
+ .chip[data-state="error"],
129
+ .chip[data-state="off"] { color: var(--color-text-danger); }
130
+ .chip[data-state="idle"] { color: var(--color-text-secondary); }
131
+
132
+ /* ── labelled value blocks ────────────────────────────────────────────── */
133
+ .field {
134
+ border: 1px solid var(--color-border-primary);
135
+ border-radius: var(--border-radius-md);
136
+ background: var(--color-background-secondary);
137
+ padding: 10px 12px;
138
+ }
139
+ .field-head {
140
+ display: flex;
141
+ align-items: baseline;
142
+ justify-content: space-between;
143
+ gap: 8px;
144
+ margin-bottom: 6px;
145
+ }
146
+ .label {
147
+ color: var(--color-text-tertiary);
148
+ font-size: 10px;
149
+ font-weight: 700;
150
+ letter-spacing: 0.08em;
151
+ text-transform: uppercase;
152
+ }
153
+ .value {
154
+ font-family: var(--font-mono);
155
+ font-size: 15px;
156
+ font-variant-numeric: tabular-nums;
157
+ overflow-wrap: anywhere;
158
+ user-select: all;
159
+ }
160
+
161
+ /* ── controls ─────────────────────────────────────────────────────────── */
162
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
163
+ .btn {
164
+ padding: 6px 13px;
165
+ border: 1px solid var(--color-border-primary);
166
+ border-radius: var(--border-radius-sm);
167
+ background: var(--color-background-tertiary);
168
+ color: var(--color-text-primary);
169
+ font: inherit;
170
+ font-size: 12px;
171
+ font-weight: 600;
172
+ cursor: pointer;
173
+ }
174
+ .btn:disabled { opacity: 0.5; cursor: default; }
175
+ .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
176
+ /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
177
+ .btn[data-armed="1"] {
178
+ color: var(--color-text-danger);
179
+ border-color: var(--color-text-danger);
180
+ }
181
+ .btn.quiet {
182
+ background: transparent;
183
+ color: var(--color-text-secondary);
184
+ }
185
+ .btn.quiet:hover { color: var(--color-text-primary); }
186
+ .copy {
187
+ flex: none;
188
+ padding: 3px 9px;
189
+ border: 1px solid var(--color-border-primary);
190
+ border-radius: var(--border-radius-sm);
191
+ background: var(--color-background-primary);
192
+ color: var(--color-text-secondary);
193
+ font: inherit;
194
+ font-size: 11px;
195
+ font-weight: 600;
196
+ cursor: pointer;
197
+ }
198
+ .copy:hover { color: var(--color-text-primary); }
199
+ .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
200
+
201
+ input[type="text"], select {
202
+ width: 100%;
203
+ padding: 6px 9px;
204
+ border: 1px solid var(--color-border-primary);
205
+ border-radius: var(--border-radius-sm);
206
+ background: var(--color-background-primary);
207
+ color: var(--color-text-primary);
208
+ font: inherit;
209
+ font-size: 13px;
210
+ }
211
+ input[type="text"] { font-family: var(--font-mono); }
212
+ input:focus-visible, select:focus-visible {
213
+ outline: 2px solid var(--color-text-secondary);
214
+ outline-offset: 1px;
215
+ }
216
+ .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
217
+ .check input { margin: 0; }
218
+
219
+ /* ── notes ────────────────────────────────────────────────────────────── */
220
+ .note { color: var(--color-text-secondary); font-size: 11.5px; }
221
+ .note strong { color: var(--color-text-primary); font-weight: 600; }
222
+ .warn { color: var(--color-text-danger); }
223
+ .rest { color: var(--color-text-secondary); font-size: 13px; }
224
+
225
+ [hidden] { display: none !important; }
226
+
227
+ @media (prefers-reduced-motion: no-preference) {
228
+ .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
229
+ }
230
+
231
+
232
+ /* An address is checked at its ENDS. Grouping it and dimming the middle is
233
+ what makes "did I paste the right one?" answerable at a glance. */
234
+ .addr { display: flex; flex-wrap: wrap; gap: 0 7px; line-height: 1.6; }
235
+ .g { color: var(--color-text-secondary); }
236
+ .g.edge { color: var(--color-text-primary); font-weight: 600; }
237
+
238
+ /* Only rendered when there is a real choice of rail. */
239
+ .rails { display: flex; flex-wrap: wrap; gap: 6px; }
240
+ .rail {
241
+ padding: 5px 11px;
242
+ border: 1px solid var(--color-border-primary);
243
+ border-radius: var(--border-radius-sm);
244
+ background: transparent;
245
+ color: var(--color-text-secondary);
246
+ font: inherit;
247
+ font-size: 12px;
248
+ font-weight: 600;
249
+ cursor: pointer;
250
+ }
251
+ .rail[aria-selected="true"] {
252
+ background: var(--color-background-tertiary);
253
+ color: var(--color-text-primary);
254
+ }
255
+ .rail:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
256
+ </style>
257
+ </head>
258
+ <body>
259
+ <div class="card">
260
+ <div class="head">
261
+ <div>
262
+ <div class="total" id="total">—</div>
263
+ <div class="sub" id="sub">Waiting for payment details…</div>
264
+ </div>
265
+ <div class="chip" id="chip" data-state="pending">Pending</div>
266
+ </div>
267
+
268
+ <div class="rails" id="rails" hidden role="tablist" aria-label="Payment currency"></div>
269
+
270
+ <div id="pay" hidden>
271
+ <div class="field" style="margin-bottom:10px">
272
+ <div class="field-head">
273
+ <span class="label">Send exactly</span>
274
+ <button class="copy" id="copyAmount" type="button">Copy</button>
275
+ </div>
276
+ <div class="value" id="amount"></div>
277
+ <div class="sub" id="rate" hidden></div>
278
+ </div>
279
+
280
+ <!-- Address last, closest to the action: a wrong amount can be topped up,
281
+ a wrong address cannot be recovered. -->
282
+ <div class="field">
283
+ <div class="field-head">
284
+ <span class="label" id="addrLabel">To this address</span>
285
+ <button class="copy" id="copyAddr" type="button">Copy</button>
286
+ </div>
287
+ <div class="value addr" id="addr"></div>
288
+ </div>
289
+ </div>
290
+
291
+ <div class="rest" id="rest" hidden></div>
292
+
293
+ <div class="actions">
294
+ <button class="btn" id="check" type="button" hidden>Check payment</button>
295
+ <button class="btn" id="wallet" type="button" hidden>Open in wallet</button>
296
+ <span class="note" id="status"></span>
297
+ </div>
298
+
299
+ <div class="note" id="verify" hidden>
300
+ Check the <strong>highlighted first and last groups</strong> of the address against
301
+ your wallet before sending. Send the exact amount — a short payment credits short.
302
+ </div>
303
+ </div>
304
+
305
+ <script>
306
+ // Impreza Host — the MCP Apps host bridge, shared by every panel.
307
+ //
308
+ // Substituted into each panel's BRIDGE marker by McpUi::read() and by
309
+ // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
310
+ // nothing from the network, so the only alternatives to substitution were three
311
+ // copies of this file or a build step. One mechanism, one place the verifier has
312
+ // to lint, and the panels stay readable.
313
+ //
314
+ // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
315
+ // · No network. Every value arrives over this bridge.
316
+ // · Never innerHTML. Tool values reach the DOM through textContent only.
317
+ // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
318
+ // an @import from a font service and its own applyHostFonts() injects it
319
+ // verbatim, which on our panels is a third-party request identifying the
320
+ // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
321
+
322
+ 'use strict';
323
+
324
+ var Bridge = (function () {
325
+ var nextId = 1;
326
+ var pending = Object.create(null);
327
+ var handlers = Object.create(null);
328
+ var hostCaps = {};
329
+
330
+ window.addEventListener('message', function (event) {
331
+ var msg = event.data;
332
+ if (!msg || msg.jsonrpc !== '2.0') { return; }
333
+ if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
334
+ var p = pending[msg.id];
335
+ delete pending[msg.id];
336
+ if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
337
+ else { p.resolve(msg.result); }
338
+ return;
339
+ }
340
+ if (msg.method && handlers[msg.method]) {
341
+ // A malformed notification must not take the panel down with it.
342
+ try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
343
+ }
344
+ });
345
+
346
+ function request(method, params) {
347
+ var id = nextId++;
348
+ return new Promise(function (resolve, reject) {
349
+ pending[id] = { resolve: resolve, reject: reject };
350
+ window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
351
+ });
352
+ }
353
+
354
+ function notify(method, params) {
355
+ window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
356
+ }
357
+
358
+ function on(method, fn) { handlers[method] = fn; }
359
+
360
+ /**
361
+ * Call one of OUR tools through the host. The host decides whether to
362
+ * forward it and may ask the viewer first — a panel never assumes consent.
363
+ * Resolves with the tool's structuredContent when it has one, falling back to
364
+ * parsing the text block for a host that only forwards \`content\`.
365
+ */
366
+ function callTool(name, args) {
367
+ return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
368
+ res = res || {};
369
+ if (res.isError) {
370
+ var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
371
+ throw new Error(text);
372
+ }
373
+ return unwrap(res);
374
+ });
375
+ }
376
+
377
+ /** structuredContent if present, else the text block parsed as JSON. */
378
+ function unwrap(payload) {
379
+ if (!payload) { return null; }
380
+ if (payload.structuredContent) { return payload.structuredContent; }
381
+ var t = payload.content && payload.content[0] && payload.content[0].text;
382
+ if (typeof t === 'string') {
383
+ try { return JSON.parse(t); } catch (e) { return null; }
384
+ }
385
+ return null;
386
+ }
387
+
388
+ function applyStyles(styles) {
389
+ if (!styles || !styles.variables) { return; }
390
+ Object.keys(styles.variables).forEach(function (k) {
391
+ var v = styles.variables[k];
392
+ // Only custom properties, and only plain values: a variable is otherwise
393
+ // a place a host could smuggle CSS into our document.
394
+ if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
395
+ document.documentElement.style.setProperty(k, v);
396
+ }
397
+ });
398
+ // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
399
+ }
400
+
401
+ function applyTheme(theme) {
402
+ if (theme === 'light' || theme === 'dark') {
403
+ document.documentElement.style.colorScheme = theme;
404
+ }
405
+ }
406
+
407
+ var lastH = 0;
408
+ function reportSize() {
409
+ var h = Math.ceil(document.documentElement.scrollHeight);
410
+ var w = Math.ceil(document.documentElement.scrollWidth);
411
+ if (h === lastH) { return; }
412
+ lastH = h;
413
+ notify('ui/notifications/size-changed', { width: w, height: h });
414
+ }
415
+
416
+ /**
417
+ * Handshake, then hand the panel its data.
418
+ *
419
+ * onResult receives the tool result (unwrapped), onInput the tool arguments,
420
+ * onFail a message when there is no bridge or the call was cancelled.
421
+ */
422
+ function start(opts) {
423
+ opts = opts || {};
424
+ var onResult = opts.onResult || function () {};
425
+ var onInput = opts.onInput || function () {};
426
+ var onFail = opts.onFail || function () {};
427
+
428
+ on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
429
+ on('ui/notifications/tool-result', function (params) {
430
+ if (params && params.isError) {
431
+ var text = (params.content && params.content[0] && params.content[0].text) || '';
432
+ onFail(text || 'The tool call failed.');
433
+ return;
434
+ }
435
+ var data = unwrap(params);
436
+ if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
437
+ reportSize();
438
+ });
439
+ on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
440
+ on('ui/notifications/host-context-changed', function (params) {
441
+ if (!params) { return; }
442
+ if (params.theme) { applyTheme(params.theme); }
443
+ if (params.styles) { applyStyles(params.styles); }
444
+ });
445
+
446
+ if (window.ResizeObserver) {
447
+ new ResizeObserver(reportSize).observe(document.body);
448
+ }
449
+
450
+ return request('ui/initialize', {
451
+ protocolVersion: '2026-01-26',
452
+ clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
453
+ appCapabilities: {}
454
+ }).then(function (result) {
455
+ result = result || {};
456
+ hostCaps = result.capabilities || {};
457
+ var ctx = result.hostContext || {};
458
+ applyTheme(ctx.theme);
459
+ applyStyles(ctx.styles);
460
+ notify('ui/notifications/initialized', {});
461
+ reportSize();
462
+ return { capabilities: hostCaps, hostContext: ctx };
463
+ }, function () {
464
+ // Standalone, with no host on the other end. Saying so beats a spinner
465
+ // that never resolves.
466
+ onFail('This panel needs an MCP host to load its data.');
467
+ throw new Error('no host bridge');
468
+ });
469
+ }
470
+
471
+ /** Ask the host to open a URL. Only offered when it says it can. */
472
+ function openLink(url) {
473
+ return request('ui/open-link', { url: String(url) });
474
+ }
475
+
476
+ function canOpenLinks() { return !!hostCaps.openLinks; }
477
+
478
+ /** Copy text, with a fallback because permissions are never guaranteed. */
479
+ function copy(text, button, okLabel, failLabel) {
480
+ var done = function (ok) {
481
+ var original = button.dataset.label || button.textContent;
482
+ button.dataset.label = original;
483
+ button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
484
+ setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
485
+ };
486
+ if (navigator.clipboard && navigator.clipboard.writeText) {
487
+ navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
488
+ return;
489
+ }
490
+ done(false);
491
+ }
492
+
493
+ return {
494
+ request: request,
495
+ notify: notify,
496
+ on: on,
497
+ callTool: callTool,
498
+ unwrap: unwrap,
499
+ start: start,
500
+ reportSize: reportSize,
501
+ openLink: openLink,
502
+ canOpenLinks: canOpenLinks,
503
+ copy: copy
504
+ };
505
+ })();
506
+
507
+ /** Shorthand every panel uses. */
508
+ function el(id) { return document.getElementById(id); }
509
+
510
+
511
+ (function () {
512
+ 'use strict';
513
+
514
+ var options = [];
515
+ var picked = 0;
516
+ var invoiceId = null;
517
+ var busy = false;
518
+
519
+ function setChip(state, text) {
520
+ var chip = el('chip');
521
+ chip.dataset.state = state;
522
+ chip.textContent = text;
523
+ }
524
+
525
+ function money(amount, currency) {
526
+ var n = Number(amount);
527
+ var shown = isFinite(n) ? n.toFixed(2) : String(amount == null ? '' : amount);
528
+ return shown + ' ' + String(currency || '');
529
+ }
530
+
531
+ // Groups of four. The FIRST four and the LAST four characters are the anchors
532
+ // a person actually checks against their wallet, so they are taken as the
533
+ // edges before the middle is chunked — grouping left-to-right and marking the
534
+ // final group would leave a two-character anchor on many addresses, which is
535
+ // a weaker thing to ask someone to verify.
536
+ function renderAddress(address) {
537
+ var box = el('addr');
538
+ box.textContent = '';
539
+ var s = String(address || '');
540
+ if (s === '') { return; }
541
+
542
+ var parts;
543
+ if (s.length <= 12) {
544
+ parts = [{ t: s, edge: true }];
545
+ } else {
546
+ parts = [{ t: s.slice(0, 4), edge: true }];
547
+ (s.slice(4, -4).match(/.{1,4}/g) || []).forEach(function (g) {
548
+ parts.push({ t: g, edge: false });
549
+ });
550
+ parts.push({ t: s.slice(-4), edge: true });
551
+ }
552
+
553
+ parts.forEach(function (p) {
554
+ var span = document.createElement('span');
555
+ span.className = 'g' + (p.edge ? ' edge' : '');
556
+ span.textContent = p.t;
557
+ box.appendChild(span);
558
+ });
559
+ }
560
+
561
+ function renderRails() {
562
+ var box = el('rails');
563
+ box.textContent = '';
564
+ if (options.length < 2) { box.hidden = true; return; }
565
+ box.hidden = false;
566
+ options.forEach(function (o, i) {
567
+ var b = document.createElement('button');
568
+ b.type = 'button';
569
+ b.className = 'rail';
570
+ b.setAttribute('role', 'tab');
571
+ b.setAttribute('aria-selected', i === picked ? 'true' : 'false');
572
+ b.textContent = String(o.crypto || o.rail || '?');
573
+ b.addEventListener('click', function () { picked = i; renderOption(); renderRails(); });
574
+ box.appendChild(b);
575
+ });
576
+ }
577
+
578
+ function renderOption() {
579
+ var o = options[picked];
580
+ if (!o) { el('pay').hidden = true; return; }
581
+ el('pay').hidden = false;
582
+ el('verify').hidden = false;
583
+
584
+ el('amount').textContent = String(o.crypto_amount || '') + ' ' + String(o.crypto || '');
585
+ el('addrLabel').textContent = o.network
586
+ ? 'To this address · ' + String(o.network)
587
+ : 'To this address';
588
+ renderAddress(o.address);
589
+
590
+ var rate = el('rate');
591
+ var bits = [];
592
+ if (o.rate) { bits.push('Rate ' + String(o.rate)); }
593
+ if (o.expires_at) { bits.push('Expires ' + String(o.expires_at)); }
594
+ if (o.note) { bits.push(String(o.note)); }
595
+ rate.textContent = bits.join(' · ');
596
+ rate.hidden = bits.length === 0;
597
+
598
+ // A wallet hand-off is only offered when the host says it can open links.
599
+ el('wallet').hidden = !(Bridge.canOpenLinks() && o.payment_uri);
600
+ Bridge.reportSize();
601
+ }
602
+
603
+ function renderRest(available) {
604
+ var box = el('rest');
605
+ if (!available) { box.hidden = true; return; }
606
+ // Whatever is already a tab is not "also payable in" — repeating it reads
607
+ // as a second, worse route to the same thing.
608
+ var shown = {};
609
+ options.forEach(function (o) { shown[String(o.crypto || '').toUpperCase()] = true; });
610
+ var more = [];
611
+ var add = function (c) {
612
+ var k = String(c).toUpperCase();
613
+ if (!shown[k]) { shown[k] = true; more.push(k); }
614
+ };
615
+ (available.tronpay || []).forEach(add);
616
+ (available.altcoins_via_fixedfloat || []).forEach(add);
617
+ if (more.length === 0) { box.hidden = true; return; }
618
+ box.hidden = false;
619
+ box.textContent = 'Also payable in ' + more.slice(0, 12).join(', ')
620
+ + (more.length > 12 ? ' and more' : '')
621
+ + ' — ask for that coin to get its own address.';
622
+ }
623
+
624
+ function render(data) {
625
+ if (!data || typeof data !== 'object') { return; }
626
+ invoiceId = data.invoice_id != null ? data.invoice_id : invoiceId;
627
+
628
+ el('total').textContent = money(data.fiat_amount, data.currency);
629
+ el('sub').textContent = invoiceId != null ? ('Top-up invoice #' + String(invoiceId)) : '';
630
+
631
+ var paid = String(data.status || '').toLowerCase() === 'paid';
632
+ setChip(paid ? 'paid' : 'pending', paid ? 'Paid' : 'Awaiting payment');
633
+
634
+ options = Array.isArray(data.payment_options) ? data.payment_options : [];
635
+ if (picked >= options.length) { picked = 0; }
636
+
637
+ if (paid) {
638
+ el('pay').hidden = true;
639
+ el('rails').hidden = true;
640
+ el('verify').hidden = true;
641
+ el('rest').hidden = true;
642
+ el('wallet').hidden = true;
643
+ el('status').textContent = data.balance != null
644
+ ? ('Credited. Account balance is now ' + money(data.balance, data.currency) + '.')
645
+ : 'Credited to the account balance.';
646
+ } else {
647
+ renderRails();
648
+ renderOption();
649
+ renderRest(data.available);
650
+ }
651
+
652
+ el('check').hidden = paid || invoiceId == null;
653
+ Bridge.reportSize();
654
+ }
655
+
656
+ function fail(text) {
657
+ setChip('error', 'Unavailable');
658
+ el('sub').textContent = '';
659
+ var s = el('status');
660
+ s.className = 'note warn';
661
+ s.textContent = String(text || 'Could not load the payment details.');
662
+ Bridge.reportSize();
663
+ }
664
+
665
+ el('copyAmount').addEventListener('click', function () {
666
+ var o = options[picked];
667
+ if (o) { Bridge.copy(String(o.crypto_amount || ''), this); }
668
+ });
669
+ el('copyAddr').addEventListener('click', function () {
670
+ var o = options[picked];
671
+ if (o) { Bridge.copy(String(o.address || ''), this); }
672
+ });
673
+
674
+ el('wallet').addEventListener('click', function () {
675
+ var o = options[picked];
676
+ if (!o || !o.payment_uri) { return; }
677
+ Bridge.openLink(o.payment_uri).catch(function () {
678
+ el('status').textContent = 'The host declined to open the wallet link — copy the address instead.';
679
+ });
680
+ });
681
+
682
+ // The one server call this panel makes, and the mildest one available: a
683
+ // read-scope status poll on the caller's own invoice.
684
+ el('check').addEventListener('click', function () {
685
+ if (busy || invoiceId == null) { return; }
686
+ busy = true;
687
+ var btn = this;
688
+ btn.disabled = true;
689
+ el('status').className = 'note';
690
+ el('status').textContent = 'Checking…';
691
+ Bridge.callTool('impreza_topup_status', { invoice_id: invoiceId })
692
+ .then(function (data) {
693
+ if (data && String(data.status || '').toLowerCase() === 'paid') {
694
+ render({
695
+ invoice_id: invoiceId,
696
+ fiat_amount: data.amount != null ? data.amount : data.fiat_amount,
697
+ currency: data.currency,
698
+ status: 'paid',
699
+ balance: data.balance
700
+ });
701
+ } else {
702
+ el('status').textContent = 'Not confirmed yet. On-chain confirmation usually takes a few minutes.';
703
+ }
704
+ })
705
+ .catch(function (e) {
706
+ el('status').className = 'note warn';
707
+ el('status').textContent = e.message || 'The check failed. Try again in a moment.';
708
+ })
709
+ .then(function () {
710
+ busy = false;
711
+ btn.disabled = false;
712
+ Bridge.reportSize();
713
+ });
714
+ });
715
+
716
+ Bridge.start({ name: 'impreza-topup-card', onResult: render, onFail: fail });
717
+ })();
718
+ </script>
719
+ </body>
720
+ </html>
721
721
  `;
722
722
  /** md5 of the canonical lib/ui/topup-card.html, LF-normalised. */
723
723
  export const TOPUP_CARD_HTML_MD5 = 'f846b1452b3d8d41166ef89351c0da2b';
724
- export const SERVER_CARD_HTML = `<!DOCTYPE html>
725
- <html lang="en">
726
- <head>
727
- <meta charset="utf-8">
728
-
729
- <title>Server</title>
730
- <!--
731
- Impreza Host — MCP Apps view for impreza_vps_status.
732
-
733
- The control panel that needs no clientarea login: what the machine is, how
734
- hard it is working, and the power controls, in the chat where the customer
735
- already is.
736
-
737
- Two decisions worth knowing:
738
-
739
- · The card is seeded from \`structuredContent\` (the live numbers, immediately)
740
- plus \`ui/notifications/tool-input\` (which carries the service_id the tool
741
- was called with — the status result itself does not contain it). It then
742
- makes ONE enrichment call for the hardware identity, which never changes.
743
- Deployments load only when asked: most people opening a server card want to
744
- know whether it is up, not to enumerate apps.
745
-
746
- · Reboot and shutdown ARM on the first click and fire on the second. The host
747
- adds its own consent gate for a tool call from a view, but a mis-click that
748
- reboots a production VM costs someone an afternoon, and the panel is the
749
- right place to make intent explicit. Start fires directly: it can only help.
750
-
751
- This panel deliberately calls no confirmation-gated tool. Those exist because
752
- intent must be proven across two SEPARATE calls, and a view that did both
753
- halves on one click would defeat the gate rather than satisfy it.
754
- -->
755
- <style>
756
- /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
757
- *
758
- * Substituted into each panel's TOKENS marker by McpUi::read() and by
759
- * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
760
- * nothing from the network, so the choice was one substitution or three copies.
761
- * Keeping it in one file is also what makes our panels read as one family
762
- * rather than three pages that happen to ship together.
763
- *
764
- * Host-provided variables win. These fallbacks are what a panel looks like when
765
- * a host sends none — which the spec warns is common, along with partial sets.
766
- * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
767
- */
768
-
769
- :root {
770
- color-scheme: light dark;
771
-
772
- --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
773
- --color-background-secondary: light-dark(#f4f3ef, #232320);
774
- --color-background-tertiary: light-dark(#eceae4, #2b2a26);
775
- --color-text-primary: light-dark(#1a1a17, #f2f1ed);
776
- --color-text-secondary: light-dark(#6e6d66, #a5a49c);
777
- --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
778
- --color-text-warning: light-dark(#8a5a00, #e0a83a);
779
- --color-text-success: light-dark(#1f6b3a, #5fbf83);
780
- --color-text-danger: light-dark(#9a2b21, #f08a7e);
781
- --color-border-primary: light-dark(#e5e4de, #33302b);
782
- --color-border-secondary: light-dark(#efeee9, #2a2825);
783
-
784
- --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
785
- --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
786
-
787
- --border-radius-sm: 6px;
788
- --border-radius-md: 10px;
789
- --border-radius-full: 999px;
790
- }
791
-
792
- * { box-sizing: border-box; }
793
-
794
- body {
795
- margin: 0;
796
- background: var(--color-background-primary);
797
- color: var(--color-text-primary);
798
- font-family: var(--font-sans);
799
- font-size: 14px;
800
- line-height: 1.45;
801
- -webkit-font-smoothing: antialiased;
802
- }
803
-
804
- .card {
805
- display: flex;
806
- flex-direction: column;
807
- gap: 14px;
808
- padding: 16px;
809
- max-width: 560px;
810
- }
811
-
812
- /* ── header ───────────────────────────────────────────────────────────── */
813
- .head {
814
- display: flex;
815
- align-items: flex-start;
816
- justify-content: space-between;
817
- gap: 12px;
818
- }
819
- .title {
820
- font-family: var(--font-mono);
821
- font-size: 18px;
822
- letter-spacing: -0.01em;
823
- overflow-wrap: anywhere;
824
- }
825
- .total {
826
- font-family: var(--font-mono);
827
- font-size: 22px;
828
- font-variant-numeric: tabular-nums;
829
- letter-spacing: -0.01em;
830
- }
831
- .sub {
832
- margin-top: 2px;
833
- color: var(--color-text-secondary);
834
- font-size: 12px;
835
- }
836
-
837
- /* State, and state only — the one place colour is allowed to catch the eye. */
838
- .chip {
839
- flex: none;
840
- padding: 3px 10px;
841
- border: 1px solid var(--color-border-primary);
842
- border-radius: var(--border-radius-full);
843
- font-size: 11px;
844
- font-weight: 600;
845
- letter-spacing: 0.04em;
846
- text-transform: uppercase;
847
- white-space: nowrap;
848
- }
849
- .chip[data-state="pending"] { color: var(--color-text-warning); }
850
- .chip[data-state="paid"],
851
- .chip[data-state="ok"] { color: var(--color-text-success); }
852
- .chip[data-state="error"],
853
- .chip[data-state="off"] { color: var(--color-text-danger); }
854
- .chip[data-state="idle"] { color: var(--color-text-secondary); }
855
-
856
- /* ── labelled value blocks ────────────────────────────────────────────── */
857
- .field {
858
- border: 1px solid var(--color-border-primary);
859
- border-radius: var(--border-radius-md);
860
- background: var(--color-background-secondary);
861
- padding: 10px 12px;
862
- }
863
- .field-head {
864
- display: flex;
865
- align-items: baseline;
866
- justify-content: space-between;
867
- gap: 8px;
868
- margin-bottom: 6px;
869
- }
870
- .label {
871
- color: var(--color-text-tertiary);
872
- font-size: 10px;
873
- font-weight: 700;
874
- letter-spacing: 0.08em;
875
- text-transform: uppercase;
876
- }
877
- .value {
878
- font-family: var(--font-mono);
879
- font-size: 15px;
880
- font-variant-numeric: tabular-nums;
881
- overflow-wrap: anywhere;
882
- user-select: all;
883
- }
884
-
885
- /* ── controls ─────────────────────────────────────────────────────────── */
886
- .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
887
- .btn {
888
- padding: 6px 13px;
889
- border: 1px solid var(--color-border-primary);
890
- border-radius: var(--border-radius-sm);
891
- background: var(--color-background-tertiary);
892
- color: var(--color-text-primary);
893
- font: inherit;
894
- font-size: 12px;
895
- font-weight: 600;
896
- cursor: pointer;
897
- }
898
- .btn:disabled { opacity: 0.5; cursor: default; }
899
- .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
900
- /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
901
- .btn[data-armed="1"] {
902
- color: var(--color-text-danger);
903
- border-color: var(--color-text-danger);
904
- }
905
- .btn.quiet {
906
- background: transparent;
907
- color: var(--color-text-secondary);
908
- }
909
- .btn.quiet:hover { color: var(--color-text-primary); }
910
- .copy {
911
- flex: none;
912
- padding: 3px 9px;
913
- border: 1px solid var(--color-border-primary);
914
- border-radius: var(--border-radius-sm);
915
- background: var(--color-background-primary);
916
- color: var(--color-text-secondary);
917
- font: inherit;
918
- font-size: 11px;
919
- font-weight: 600;
920
- cursor: pointer;
921
- }
922
- .copy:hover { color: var(--color-text-primary); }
923
- .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
924
-
925
- input[type="text"], select {
926
- width: 100%;
927
- padding: 6px 9px;
928
- border: 1px solid var(--color-border-primary);
929
- border-radius: var(--border-radius-sm);
930
- background: var(--color-background-primary);
931
- color: var(--color-text-primary);
932
- font: inherit;
933
- font-size: 13px;
934
- }
935
- input[type="text"] { font-family: var(--font-mono); }
936
- input:focus-visible, select:focus-visible {
937
- outline: 2px solid var(--color-text-secondary);
938
- outline-offset: 1px;
939
- }
940
- .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
941
- .check input { margin: 0; }
942
-
943
- /* ── notes ────────────────────────────────────────────────────────────── */
944
- .note { color: var(--color-text-secondary); font-size: 11.5px; }
945
- .note strong { color: var(--color-text-primary); font-weight: 600; }
946
- .warn { color: var(--color-text-danger); }
947
- .rest { color: var(--color-text-secondary); font-size: 13px; }
948
-
949
- [hidden] { display: none !important; }
950
-
951
- @media (prefers-reduced-motion: no-preference) {
952
- .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
953
- }
954
-
955
-
956
- /* ── identity strip ───────────────────────────────────────────────────── */
957
- .facts {
958
- display: flex;
959
- flex-wrap: wrap;
960
- gap: 4px 16px;
961
- color: var(--color-text-secondary);
962
- font-size: 12px;
963
- }
964
- .facts b {
965
- color: var(--color-text-primary);
966
- font-family: var(--font-mono);
967
- font-weight: 600;
968
- }
969
-
970
- /* ── meters: real scales, and every label names a value they reach ────── */
971
- .meters { display: flex; flex-direction: column; gap: 10px; }
972
- .meter-head {
973
- display: flex;
974
- align-items: baseline;
975
- justify-content: space-between;
976
- gap: 8px;
977
- margin-bottom: 4px;
978
- }
979
- .meter-val {
980
- font-family: var(--font-mono);
981
- font-size: 12px;
982
- font-variant-numeric: tabular-nums;
983
- color: var(--color-text-secondary);
984
- }
985
- .track {
986
- height: 6px;
987
- border-radius: var(--border-radius-full);
988
- background: var(--color-background-tertiary);
989
- overflow: hidden;
990
- }
991
- .fill {
992
- height: 100%;
993
- width: 0;
994
- border-radius: var(--border-radius-full);
995
- background: var(--color-text-secondary);
996
- }
997
- /* Pressure, not decoration: the bar changes colour only where it matters. */
998
- .fill[data-load="high"] { background: var(--color-text-warning); }
999
- .fill[data-load="full"] { background: var(--color-text-danger); }
1000
-
1001
- /* ── deployments ──────────────────────────────────────────────────────── */
1002
- .apps { display: flex; flex-direction: column; gap: 6px; }
1003
- .app {
1004
- display: flex;
1005
- align-items: baseline;
1006
- justify-content: space-between;
1007
- gap: 10px;
1008
- padding: 7px 10px;
1009
- border: 1px solid var(--color-border-secondary);
1010
- border-radius: var(--border-radius-sm);
1011
- font-size: 12.5px;
1012
- }
1013
- .app-name { font-family: var(--font-mono); font-weight: 600; }
1014
- .app-host { color: var(--color-text-secondary); overflow-wrap: anywhere; }
1015
- .app-state { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
1016
- .app-state[data-state="ok"] { color: var(--color-text-success); }
1017
- .app-state[data-state="work"] { color: var(--color-text-warning); }
1018
- .app-state[data-state="bad"] { color: var(--color-text-danger); }
1019
- .app-state[data-state="gone"] { color: var(--color-text-tertiary); }
1020
- </style>
1021
- </head>
1022
- <body>
1023
- <div class="card">
1024
- <div class="head">
1025
- <div>
1026
- <div class="title" id="host">—</div>
1027
- <div class="sub" id="sub">Loading server details…</div>
1028
- </div>
1029
- <div class="chip" id="chip" data-state="idle">Unknown</div>
1030
- </div>
1031
-
1032
- <div class="facts" id="facts" hidden></div>
1033
-
1034
- <div class="meters" id="meters" hidden>
1035
- <div>
1036
- <div class="meter-head">
1037
- <span class="label">CPU</span>
1038
- <span class="meter-val" id="cpuVal"></span>
1039
- </div>
1040
- <div class="track"><div class="fill" id="cpuFill"></div></div>
1041
- </div>
1042
- <div>
1043
- <div class="meter-head">
1044
- <span class="label">Memory</span>
1045
- <span class="meter-val" id="memVal"></span>
1046
- </div>
1047
- <div class="track"><div class="fill" id="memFill"></div></div>
1048
- </div>
1049
- </div>
1050
-
1051
- <div class="actions">
1052
- <button class="btn" id="refresh" type="button">Refresh</button>
1053
- <button class="btn" id="start" type="button" hidden>Start</button>
1054
- <button class="btn" id="reboot" type="button" hidden>Reboot</button>
1055
- <button class="btn" id="shutdown" type="button" hidden>Shut down</button>
1056
- <span class="note" id="status"></span>
1057
- </div>
1058
-
1059
- <div>
1060
- <button class="btn quiet" id="appsToggle" type="button">Show deployed apps</button>
1061
- <div class="apps" id="apps" hidden style="margin-top:10px"></div>
1062
- </div>
1063
- </div>
1064
-
1065
- <script>
1066
- // Impreza Host — the MCP Apps host bridge, shared by every panel.
1067
- //
1068
- // Substituted into each panel's BRIDGE marker by McpUi::read() and by
1069
- // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
1070
- // nothing from the network, so the only alternatives to substitution were three
1071
- // copies of this file or a build step. One mechanism, one place the verifier has
1072
- // to lint, and the panels stay readable.
1073
- //
1074
- // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
1075
- // · No network. Every value arrives over this bridge.
1076
- // · Never innerHTML. Tool values reach the DOM through textContent only.
1077
- // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
1078
- // an @import from a font service and its own applyHostFonts() injects it
1079
- // verbatim, which on our panels is a third-party request identifying the
1080
- // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
1081
-
1082
- 'use strict';
1083
-
1084
- var Bridge = (function () {
1085
- var nextId = 1;
1086
- var pending = Object.create(null);
1087
- var handlers = Object.create(null);
1088
- var hostCaps = {};
1089
-
1090
- window.addEventListener('message', function (event) {
1091
- var msg = event.data;
1092
- if (!msg || msg.jsonrpc !== '2.0') { return; }
1093
- if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
1094
- var p = pending[msg.id];
1095
- delete pending[msg.id];
1096
- if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
1097
- else { p.resolve(msg.result); }
1098
- return;
1099
- }
1100
- if (msg.method && handlers[msg.method]) {
1101
- // A malformed notification must not take the panel down with it.
1102
- try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
1103
- }
1104
- });
1105
-
1106
- function request(method, params) {
1107
- var id = nextId++;
1108
- return new Promise(function (resolve, reject) {
1109
- pending[id] = { resolve: resolve, reject: reject };
1110
- window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
1111
- });
1112
- }
1113
-
1114
- function notify(method, params) {
1115
- window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
1116
- }
1117
-
1118
- function on(method, fn) { handlers[method] = fn; }
1119
-
1120
- /**
1121
- * Call one of OUR tools through the host. The host decides whether to
1122
- * forward it and may ask the viewer first — a panel never assumes consent.
1123
- * Resolves with the tool's structuredContent when it has one, falling back to
1124
- * parsing the text block for a host that only forwards \`content\`.
1125
- */
1126
- function callTool(name, args) {
1127
- return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
1128
- res = res || {};
1129
- if (res.isError) {
1130
- var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
1131
- throw new Error(text);
1132
- }
1133
- return unwrap(res);
1134
- });
1135
- }
1136
-
1137
- /** structuredContent if present, else the text block parsed as JSON. */
1138
- function unwrap(payload) {
1139
- if (!payload) { return null; }
1140
- if (payload.structuredContent) { return payload.structuredContent; }
1141
- var t = payload.content && payload.content[0] && payload.content[0].text;
1142
- if (typeof t === 'string') {
1143
- try { return JSON.parse(t); } catch (e) { return null; }
1144
- }
1145
- return null;
1146
- }
1147
-
1148
- function applyStyles(styles) {
1149
- if (!styles || !styles.variables) { return; }
1150
- Object.keys(styles.variables).forEach(function (k) {
1151
- var v = styles.variables[k];
1152
- // Only custom properties, and only plain values: a variable is otherwise
1153
- // a place a host could smuggle CSS into our document.
1154
- if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
1155
- document.documentElement.style.setProperty(k, v);
1156
- }
1157
- });
1158
- // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
1159
- }
1160
-
1161
- function applyTheme(theme) {
1162
- if (theme === 'light' || theme === 'dark') {
1163
- document.documentElement.style.colorScheme = theme;
1164
- }
1165
- }
1166
-
1167
- var lastH = 0;
1168
- function reportSize() {
1169
- var h = Math.ceil(document.documentElement.scrollHeight);
1170
- var w = Math.ceil(document.documentElement.scrollWidth);
1171
- if (h === lastH) { return; }
1172
- lastH = h;
1173
- notify('ui/notifications/size-changed', { width: w, height: h });
1174
- }
1175
-
1176
- /**
1177
- * Handshake, then hand the panel its data.
1178
- *
1179
- * onResult receives the tool result (unwrapped), onInput the tool arguments,
1180
- * onFail a message when there is no bridge or the call was cancelled.
1181
- */
1182
- function start(opts) {
1183
- opts = opts || {};
1184
- var onResult = opts.onResult || function () {};
1185
- var onInput = opts.onInput || function () {};
1186
- var onFail = opts.onFail || function () {};
1187
-
1188
- on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
1189
- on('ui/notifications/tool-result', function (params) {
1190
- if (params && params.isError) {
1191
- var text = (params.content && params.content[0] && params.content[0].text) || '';
1192
- onFail(text || 'The tool call failed.');
1193
- return;
1194
- }
1195
- var data = unwrap(params);
1196
- if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
1197
- reportSize();
1198
- });
1199
- on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
1200
- on('ui/notifications/host-context-changed', function (params) {
1201
- if (!params) { return; }
1202
- if (params.theme) { applyTheme(params.theme); }
1203
- if (params.styles) { applyStyles(params.styles); }
1204
- });
1205
-
1206
- if (window.ResizeObserver) {
1207
- new ResizeObserver(reportSize).observe(document.body);
1208
- }
1209
-
1210
- return request('ui/initialize', {
1211
- protocolVersion: '2026-01-26',
1212
- clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
1213
- appCapabilities: {}
1214
- }).then(function (result) {
1215
- result = result || {};
1216
- hostCaps = result.capabilities || {};
1217
- var ctx = result.hostContext || {};
1218
- applyTheme(ctx.theme);
1219
- applyStyles(ctx.styles);
1220
- notify('ui/notifications/initialized', {});
1221
- reportSize();
1222
- return { capabilities: hostCaps, hostContext: ctx };
1223
- }, function () {
1224
- // Standalone, with no host on the other end. Saying so beats a spinner
1225
- // that never resolves.
1226
- onFail('This panel needs an MCP host to load its data.');
1227
- throw new Error('no host bridge');
1228
- });
1229
- }
1230
-
1231
- /** Ask the host to open a URL. Only offered when it says it can. */
1232
- function openLink(url) {
1233
- return request('ui/open-link', { url: String(url) });
1234
- }
1235
-
1236
- function canOpenLinks() { return !!hostCaps.openLinks; }
1237
-
1238
- /** Copy text, with a fallback because permissions are never guaranteed. */
1239
- function copy(text, button, okLabel, failLabel) {
1240
- var done = function (ok) {
1241
- var original = button.dataset.label || button.textContent;
1242
- button.dataset.label = original;
1243
- button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
1244
- setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
1245
- };
1246
- if (navigator.clipboard && navigator.clipboard.writeText) {
1247
- navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
1248
- return;
1249
- }
1250
- done(false);
1251
- }
1252
-
1253
- return {
1254
- request: request,
1255
- notify: notify,
1256
- on: on,
1257
- callTool: callTool,
1258
- unwrap: unwrap,
1259
- start: start,
1260
- reportSize: reportSize,
1261
- openLink: openLink,
1262
- canOpenLinks: canOpenLinks,
1263
- copy: copy
1264
- };
1265
- })();
1266
-
1267
- /** Shorthand every panel uses. */
1268
- function el(id) { return document.getElementById(id); }
1269
-
1270
-
1271
- (function () {
1272
- 'use strict';
1273
-
1274
- var serviceId = null;
1275
- var powerState = '';
1276
- var busy = false;
1277
- var appsLoaded = false;
1278
-
1279
- // ── formatting ──────────────────────────────────────────────────────────
1280
- function gib(bytes) {
1281
- var n = Number(bytes);
1282
- if (!isFinite(n) || n <= 0) { return null; }
1283
- return n / 1073741824;
1284
- }
1285
-
1286
- function uptime(seconds) {
1287
- var s = Number(seconds);
1288
- if (!isFinite(s) || s <= 0) { return null; }
1289
- var d = Math.floor(s / 86400);
1290
- var h = Math.floor((s % 86400) / 3600);
1291
- var m = Math.floor((s % 3600) / 60);
1292
- if (d > 0) { return d + 'd ' + h + 'h'; }
1293
- if (h > 0) { return h + 'h ' + m + 'm'; }
1294
- return m + 'm';
1295
- }
1296
-
1297
- function load(pct) {
1298
- if (pct >= 90) { return 'full'; }
1299
- if (pct >= 70) { return 'high'; }
1300
- return 'normal';
1301
- }
1302
-
1303
- function setMeter(fillEl, valEl, pct, text) {
1304
- var p = Math.max(0, Math.min(100, Number(pct) || 0));
1305
- fillEl.style.width = p.toFixed(1) + '%';
1306
- fillEl.dataset.load = load(p);
1307
- valEl.textContent = text;
1308
- }
1309
-
1310
- // ── rendering ───────────────────────────────────────────────────────────
1311
- function setPower(state) {
1312
- powerState = String(state || '').toLowerCase();
1313
- var chip = el('chip');
1314
- if (powerState === 'running') {
1315
- chip.dataset.state = 'ok';
1316
- chip.textContent = 'Running';
1317
- } else if (powerState === 'stopped') {
1318
- chip.dataset.state = 'off';
1319
- chip.textContent = 'Stopped';
1320
- } else if (powerState === '') {
1321
- chip.dataset.state = 'idle';
1322
- chip.textContent = 'Unknown';
1323
- } else {
1324
- chip.dataset.state = 'pending';
1325
- chip.textContent = powerState;
1326
- }
1327
-
1328
- // Offer only what the current state makes sense for.
1329
- var running = powerState === 'running';
1330
- el('start').hidden = running || powerState === '';
1331
- el('reboot').hidden = !running;
1332
- el('shutdown').hidden = !running;
1333
- disarmAll();
1334
- }
1335
-
1336
- function renderStatus(d) {
1337
- if (!d || typeof d !== 'object') { return; }
1338
- setPower(d.power_state);
1339
-
1340
- var usedGiB = gib(d.memory_used);
1341
- var totalGiB = gib(d.memory_total);
1342
- var memPct = (usedGiB !== null && totalGiB) ? (usedGiB / totalGiB) * 100 : 0;
1343
- var cpuPct = Number(d.cpu_usage);
1344
- if (!isFinite(cpuPct)) { cpuPct = 0; }
1345
- // The upstream reports CPU as a percentage already; a 0–1 fraction would be
1346
- // a different contract, and mis-reading it would draw a flat bar forever.
1347
- if (cpuPct > 0 && cpuPct <= 1 && d.cpu_usage !== 1) { cpuPct = cpuPct * 100; }
1348
-
1349
- el('meters').hidden = false;
1350
- setMeter(el('cpuFill'), el('cpuVal'), cpuPct, cpuPct.toFixed(1) + '% of 100%');
1351
- setMeter(
1352
- el('memFill'), el('memVal'), memPct,
1353
- (usedGiB !== null && totalGiB)
1354
- ? usedGiB.toFixed(2) + ' of ' + totalGiB.toFixed(2) + ' GiB · ' + memPct.toFixed(0) + '%'
1355
- : 'not reported'
1356
- );
1357
-
1358
- var up = uptime(d.uptime);
1359
- el('sub').textContent = up ? ('Up ' + up) : '';
1360
- Bridge.reportSize();
1361
- }
1362
-
1363
- function renderIdentity(info) {
1364
- if (!info || typeof info !== 'object') { return; }
1365
- if (info.hostname) { el('host').textContent = String(info.hostname); }
1366
-
1367
- var res = info.resources || {};
1368
- var bits = [];
1369
- var push = function (label, value) {
1370
- if (value === null || value === undefined || value === '') { return; }
1371
- bits.push([label, String(value)]);
1372
- };
1373
- push('Node', info.node);
1374
- push('vCPU', res.cores);
1375
- push('RAM', res.memory_mb ? (Math.round(res.memory_mb / 1024 * 10) / 10) + ' GiB' : null);
1376
- push('Disk', res.disk_gb ? res.disk_gb + ' GB' : null);
1377
- var ips = Array.isArray(info.ips) ? info.ips.map(function (i) { return String(i.ip || ''); }).filter(Boolean) : [];
1378
- push('IP', ips.join(', '));
1379
- if (info.os) { push('OS', info.os); }
1380
-
1381
- var box = el('facts');
1382
- box.textContent = '';
1383
- bits.forEach(function (pair) {
1384
- var span = document.createElement('span');
1385
- span.appendChild(document.createTextNode(pair[0] + ' '));
1386
- var b = document.createElement('b');
1387
- b.textContent = pair[1];
1388
- span.appendChild(b);
1389
- box.appendChild(span);
1390
- });
1391
- box.hidden = bits.length === 0;
1392
- Bridge.reportSize();
1393
- }
1394
-
1395
- function appState(status) {
1396
- var s = String(status || '').toLowerCase();
1397
- if (s === 'running') { return ['ok', s]; }
1398
- if (s === 'failed') { return ['bad', s]; }
1399
- if (s === 'uninstalled') { return ['gone', s]; }
1400
- return ['work', s || 'unknown'];
1401
- }
1402
-
1403
- function renderApps(list) {
1404
- var box = el('apps');
1405
- box.textContent = '';
1406
- var rows = Array.isArray(list) ? list : [];
1407
- if (rows.length === 0) {
1408
- var none = document.createElement('div');
1409
- none.className = 'note';
1410
- none.textContent = 'No apps deployed on this server yet.';
1411
- box.appendChild(none);
1412
- Bridge.reportSize();
1413
- return;
1414
- }
1415
- rows.forEach(function (d) {
1416
- var row = document.createElement('div');
1417
- row.className = 'app';
1418
-
1419
- var left = document.createElement('div');
1420
- var name = document.createElement('div');
1421
- name.className = 'app-name';
1422
- name.textContent = String(d.app_name || d.name || d.id || '?');
1423
- left.appendChild(name);
1424
- if (d.domain) {
1425
- var host = document.createElement('div');
1426
- host.className = 'app-host';
1427
- host.textContent = String(d.domain).replace(/^https?:\\/\\//, '');
1428
- left.appendChild(host);
1429
- }
1430
- row.appendChild(left);
1431
-
1432
- var pair = appState(d.status);
1433
- var st = document.createElement('div');
1434
- st.className = 'app-state';
1435
- st.dataset.state = pair[0];
1436
- st.textContent = pair[1];
1437
- row.appendChild(st);
1438
-
1439
- box.appendChild(row);
1440
- });
1441
- Bridge.reportSize();
1442
- }
1443
-
1444
- function fail(text) {
1445
- var chip = el('chip');
1446
- chip.dataset.state = 'error';
1447
- chip.textContent = 'Unavailable';
1448
- el('sub').textContent = '';
1449
- var s = el('status');
1450
- s.className = 'note warn';
1451
- s.textContent = String(text || 'Could not load this server.');
1452
- Bridge.reportSize();
1453
- }
1454
-
1455
- function say(text, bad) {
1456
- var s = el('status');
1457
- s.className = bad ? 'note warn' : 'note';
1458
- s.textContent = text || '';
1459
- Bridge.reportSize();
1460
- }
1461
-
1462
- // ── the arm-then-fire pattern for the disruptive actions ────────────────
1463
- function disarmAll() {
1464
- ['reboot', 'shutdown'].forEach(function (id) {
1465
- var b = el(id);
1466
- b.dataset.armed = '0';
1467
- b.textContent = b.dataset.base || b.textContent;
1468
- });
1469
- }
1470
-
1471
- function wirePower(id, action, disruptive) {
1472
- var btn = el(id);
1473
- btn.dataset.base = btn.textContent;
1474
- btn.addEventListener('click', function () {
1475
- if (busy || serviceId === null) { return; }
1476
- if (disruptive && btn.dataset.armed !== '1') {
1477
- disarmAll();
1478
- btn.dataset.armed = '1';
1479
- btn.textContent = 'Confirm ' + btn.dataset.base.toLowerCase();
1480
- say('This interrupts everything running on the server. Click again to go ahead.');
1481
- return;
1482
- }
1483
- disarmAll();
1484
- busy = true;
1485
- btn.disabled = true;
1486
- say(btn.dataset.base + ' requested…');
1487
- Bridge.callTool('impreza_vps_power', { service_id: serviceId, action: action })
1488
- .then(function () {
1489
- say('Accepted. The state below refreshes as the server catches up.');
1490
- setTimeout(refreshStatus, 4000);
1491
- })
1492
- .catch(function (e) { say(e.message || 'The host declined that action.', true); })
1493
- .then(function () { busy = false; btn.disabled = false; Bridge.reportSize(); });
1494
- });
1495
- }
1496
-
1497
- // ── server calls ────────────────────────────────────────────────────────
1498
- function refreshStatus() {
1499
- if (serviceId === null) { return Promise.resolve(); }
1500
- return Bridge.callTool('impreza_vps_status', { service_id: serviceId })
1501
- .then(renderStatus)
1502
- .catch(function (e) { say(e.message || 'Could not refresh.', true); });
1503
- }
1504
-
1505
- function loadIdentity() {
1506
- if (serviceId === null) { return; }
1507
- // One enrichment call, for the facts that do not change.
1508
- Bridge.callTool('impreza_api_call', { path: '/vps/proxmox/' + serviceId })
1509
- .then(renderIdentity)
1510
- .catch(function () { /* the card is still useful without the hardware strip */ });
1511
- }
1512
-
1513
- function loadApps() {
1514
- // Two calls, and only on demand: list_servers maps this service to its
1515
- // agent, and only then can deployments be filtered to THIS machine.
1516
- // Showing another server's apps would be worse than showing none.
1517
- return Bridge.callTool('impreza_list_servers', {})
1518
- .then(function (d) {
1519
- var servers = (d && d.servers) || [];
1520
- var mine = servers.filter(function (s) { return String(s.service_id) === String(serviceId); })[0];
1521
- if (!mine || !mine.agent_id) {
1522
- renderApps([]);
1523
- return;
1524
- }
1525
- return Bridge.callTool('impreza_list_deployments', { agent_id: mine.agent_id })
1526
- .then(function (r) { renderApps((r && r.deployments) || []); });
1527
- })
1528
- .catch(function (e) { say(e.message || 'Could not list apps.', true); });
1529
- }
1530
-
1531
- // ── wiring ──────────────────────────────────────────────────────────────
1532
- el('refresh').addEventListener('click', function () {
1533
- if (busy) { return; }
1534
- say('Refreshing…');
1535
- refreshStatus().then(function () { say(''); });
1536
- });
1537
-
1538
- wirePower('start', 'start', false);
1539
- wirePower('reboot', 'reboot', true);
1540
- wirePower('shutdown', 'shutdown', true);
1541
-
1542
- el('appsToggle').addEventListener('click', function () {
1543
- var box = el('apps');
1544
- if (box.hidden) {
1545
- box.hidden = false;
1546
- this.textContent = 'Hide deployed apps';
1547
- if (!appsLoaded) { appsLoaded = true; loadApps(); }
1548
- } else {
1549
- box.hidden = true;
1550
- this.textContent = 'Show deployed apps';
1551
- }
1552
- Bridge.reportSize();
1553
- });
1554
-
1555
- Bridge.start({
1556
- name: 'impreza-server-card',
1557
- onInput: function (args) {
1558
- // The status result carries no service_id; the tool ARGUMENTS do, and
1559
- // every later call this panel makes needs it.
1560
- var raw = args && args.service_id;
1561
- if (raw !== undefined && raw !== null && String(raw) !== '') {
1562
- serviceId = Number(raw);
1563
- el('sub').textContent = 'Service #' + serviceId;
1564
- loadIdentity();
1565
- }
1566
- },
1567
- onResult: renderStatus,
1568
- onFail: fail
1569
- });
1570
- })();
1571
- </script>
1572
- </body>
1573
- </html>
724
+ export const SERVER_CARD_HTML = `<!DOCTYPE html>
725
+ <html lang="en">
726
+ <head>
727
+ <meta charset="utf-8">
728
+
729
+ <title>Server</title>
730
+ <!--
731
+ Impreza Host — MCP Apps view for impreza_vps_status.
732
+
733
+ The control panel that needs no clientarea login: what the machine is, how
734
+ hard it is working, and the power controls, in the chat where the customer
735
+ already is.
736
+
737
+ Two decisions worth knowing:
738
+
739
+ · The card is seeded from \`structuredContent\` (the live numbers, immediately)
740
+ plus \`ui/notifications/tool-input\` (which carries the service_id the tool
741
+ was called with — the status result itself does not contain it). It then
742
+ makes ONE enrichment call for the hardware identity, which never changes.
743
+ Deployments load only when asked: most people opening a server card want to
744
+ know whether it is up, not to enumerate apps.
745
+
746
+ · Reboot and shutdown ARM on the first click and fire on the second. The host
747
+ adds its own consent gate for a tool call from a view, but a mis-click that
748
+ reboots a production VM costs someone an afternoon, and the panel is the
749
+ right place to make intent explicit. Start fires directly: it can only help.
750
+
751
+ This panel deliberately calls no confirmation-gated tool. Those exist because
752
+ intent must be proven across two SEPARATE calls, and a view that did both
753
+ halves on one click would defeat the gate rather than satisfy it.
754
+ -->
755
+ <style>
756
+ /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
757
+ *
758
+ * Substituted into each panel's TOKENS marker by McpUi::read() and by
759
+ * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
760
+ * nothing from the network, so the choice was one substitution or three copies.
761
+ * Keeping it in one file is also what makes our panels read as one family
762
+ * rather than three pages that happen to ship together.
763
+ *
764
+ * Host-provided variables win. These fallbacks are what a panel looks like when
765
+ * a host sends none — which the spec warns is common, along with partial sets.
766
+ * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
767
+ */
768
+
769
+ :root {
770
+ color-scheme: light dark;
771
+
772
+ --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
773
+ --color-background-secondary: light-dark(#f4f3ef, #232320);
774
+ --color-background-tertiary: light-dark(#eceae4, #2b2a26);
775
+ --color-text-primary: light-dark(#1a1a17, #f2f1ed);
776
+ --color-text-secondary: light-dark(#6e6d66, #a5a49c);
777
+ --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
778
+ --color-text-warning: light-dark(#8a5a00, #e0a83a);
779
+ --color-text-success: light-dark(#1f6b3a, #5fbf83);
780
+ --color-text-danger: light-dark(#9a2b21, #f08a7e);
781
+ --color-border-primary: light-dark(#e5e4de, #33302b);
782
+ --color-border-secondary: light-dark(#efeee9, #2a2825);
783
+
784
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
785
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
786
+
787
+ --border-radius-sm: 6px;
788
+ --border-radius-md: 10px;
789
+ --border-radius-full: 999px;
790
+ }
791
+
792
+ * { box-sizing: border-box; }
793
+
794
+ body {
795
+ margin: 0;
796
+ background: var(--color-background-primary);
797
+ color: var(--color-text-primary);
798
+ font-family: var(--font-sans);
799
+ font-size: 14px;
800
+ line-height: 1.45;
801
+ -webkit-font-smoothing: antialiased;
802
+ }
803
+
804
+ .card {
805
+ display: flex;
806
+ flex-direction: column;
807
+ gap: 14px;
808
+ padding: 16px;
809
+ max-width: 560px;
810
+ }
811
+
812
+ /* ── header ───────────────────────────────────────────────────────────── */
813
+ .head {
814
+ display: flex;
815
+ align-items: flex-start;
816
+ justify-content: space-between;
817
+ gap: 12px;
818
+ }
819
+ .title {
820
+ font-family: var(--font-mono);
821
+ font-size: 18px;
822
+ letter-spacing: -0.01em;
823
+ overflow-wrap: anywhere;
824
+ }
825
+ .total {
826
+ font-family: var(--font-mono);
827
+ font-size: 22px;
828
+ font-variant-numeric: tabular-nums;
829
+ letter-spacing: -0.01em;
830
+ }
831
+ .sub {
832
+ margin-top: 2px;
833
+ color: var(--color-text-secondary);
834
+ font-size: 12px;
835
+ }
836
+
837
+ /* State, and state only — the one place colour is allowed to catch the eye. */
838
+ .chip {
839
+ flex: none;
840
+ padding: 3px 10px;
841
+ border: 1px solid var(--color-border-primary);
842
+ border-radius: var(--border-radius-full);
843
+ font-size: 11px;
844
+ font-weight: 600;
845
+ letter-spacing: 0.04em;
846
+ text-transform: uppercase;
847
+ white-space: nowrap;
848
+ }
849
+ .chip[data-state="pending"] { color: var(--color-text-warning); }
850
+ .chip[data-state="paid"],
851
+ .chip[data-state="ok"] { color: var(--color-text-success); }
852
+ .chip[data-state="error"],
853
+ .chip[data-state="off"] { color: var(--color-text-danger); }
854
+ .chip[data-state="idle"] { color: var(--color-text-secondary); }
855
+
856
+ /* ── labelled value blocks ────────────────────────────────────────────── */
857
+ .field {
858
+ border: 1px solid var(--color-border-primary);
859
+ border-radius: var(--border-radius-md);
860
+ background: var(--color-background-secondary);
861
+ padding: 10px 12px;
862
+ }
863
+ .field-head {
864
+ display: flex;
865
+ align-items: baseline;
866
+ justify-content: space-between;
867
+ gap: 8px;
868
+ margin-bottom: 6px;
869
+ }
870
+ .label {
871
+ color: var(--color-text-tertiary);
872
+ font-size: 10px;
873
+ font-weight: 700;
874
+ letter-spacing: 0.08em;
875
+ text-transform: uppercase;
876
+ }
877
+ .value {
878
+ font-family: var(--font-mono);
879
+ font-size: 15px;
880
+ font-variant-numeric: tabular-nums;
881
+ overflow-wrap: anywhere;
882
+ user-select: all;
883
+ }
884
+
885
+ /* ── controls ─────────────────────────────────────────────────────────── */
886
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
887
+ .btn {
888
+ padding: 6px 13px;
889
+ border: 1px solid var(--color-border-primary);
890
+ border-radius: var(--border-radius-sm);
891
+ background: var(--color-background-tertiary);
892
+ color: var(--color-text-primary);
893
+ font: inherit;
894
+ font-size: 12px;
895
+ font-weight: 600;
896
+ cursor: pointer;
897
+ }
898
+ .btn:disabled { opacity: 0.5; cursor: default; }
899
+ .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
900
+ /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
901
+ .btn[data-armed="1"] {
902
+ color: var(--color-text-danger);
903
+ border-color: var(--color-text-danger);
904
+ }
905
+ .btn.quiet {
906
+ background: transparent;
907
+ color: var(--color-text-secondary);
908
+ }
909
+ .btn.quiet:hover { color: var(--color-text-primary); }
910
+ .copy {
911
+ flex: none;
912
+ padding: 3px 9px;
913
+ border: 1px solid var(--color-border-primary);
914
+ border-radius: var(--border-radius-sm);
915
+ background: var(--color-background-primary);
916
+ color: var(--color-text-secondary);
917
+ font: inherit;
918
+ font-size: 11px;
919
+ font-weight: 600;
920
+ cursor: pointer;
921
+ }
922
+ .copy:hover { color: var(--color-text-primary); }
923
+ .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
924
+
925
+ input[type="text"], select {
926
+ width: 100%;
927
+ padding: 6px 9px;
928
+ border: 1px solid var(--color-border-primary);
929
+ border-radius: var(--border-radius-sm);
930
+ background: var(--color-background-primary);
931
+ color: var(--color-text-primary);
932
+ font: inherit;
933
+ font-size: 13px;
934
+ }
935
+ input[type="text"] { font-family: var(--font-mono); }
936
+ input:focus-visible, select:focus-visible {
937
+ outline: 2px solid var(--color-text-secondary);
938
+ outline-offset: 1px;
939
+ }
940
+ .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
941
+ .check input { margin: 0; }
942
+
943
+ /* ── notes ────────────────────────────────────────────────────────────── */
944
+ .note { color: var(--color-text-secondary); font-size: 11.5px; }
945
+ .note strong { color: var(--color-text-primary); font-weight: 600; }
946
+ .warn { color: var(--color-text-danger); }
947
+ .rest { color: var(--color-text-secondary); font-size: 13px; }
948
+
949
+ [hidden] { display: none !important; }
950
+
951
+ @media (prefers-reduced-motion: no-preference) {
952
+ .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
953
+ }
954
+
955
+
956
+ /* ── identity strip ───────────────────────────────────────────────────── */
957
+ .facts {
958
+ display: flex;
959
+ flex-wrap: wrap;
960
+ gap: 4px 16px;
961
+ color: var(--color-text-secondary);
962
+ font-size: 12px;
963
+ }
964
+ .facts b {
965
+ color: var(--color-text-primary);
966
+ font-family: var(--font-mono);
967
+ font-weight: 600;
968
+ }
969
+
970
+ /* ── meters: real scales, and every label names a value they reach ────── */
971
+ .meters { display: flex; flex-direction: column; gap: 10px; }
972
+ .meter-head {
973
+ display: flex;
974
+ align-items: baseline;
975
+ justify-content: space-between;
976
+ gap: 8px;
977
+ margin-bottom: 4px;
978
+ }
979
+ .meter-val {
980
+ font-family: var(--font-mono);
981
+ font-size: 12px;
982
+ font-variant-numeric: tabular-nums;
983
+ color: var(--color-text-secondary);
984
+ }
985
+ .track {
986
+ height: 6px;
987
+ border-radius: var(--border-radius-full);
988
+ background: var(--color-background-tertiary);
989
+ overflow: hidden;
990
+ }
991
+ .fill {
992
+ height: 100%;
993
+ width: 0;
994
+ border-radius: var(--border-radius-full);
995
+ background: var(--color-text-secondary);
996
+ }
997
+ /* Pressure, not decoration: the bar changes colour only where it matters. */
998
+ .fill[data-load="high"] { background: var(--color-text-warning); }
999
+ .fill[data-load="full"] { background: var(--color-text-danger); }
1000
+
1001
+ /* ── deployments ──────────────────────────────────────────────────────── */
1002
+ .apps { display: flex; flex-direction: column; gap: 6px; }
1003
+ .app {
1004
+ display: flex;
1005
+ align-items: baseline;
1006
+ justify-content: space-between;
1007
+ gap: 10px;
1008
+ padding: 7px 10px;
1009
+ border: 1px solid var(--color-border-secondary);
1010
+ border-radius: var(--border-radius-sm);
1011
+ font-size: 12.5px;
1012
+ }
1013
+ .app-name { font-family: var(--font-mono); font-weight: 600; }
1014
+ .app-host { color: var(--color-text-secondary); overflow-wrap: anywhere; }
1015
+ .app-state { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
1016
+ .app-state[data-state="ok"] { color: var(--color-text-success); }
1017
+ .app-state[data-state="work"] { color: var(--color-text-warning); }
1018
+ .app-state[data-state="bad"] { color: var(--color-text-danger); }
1019
+ .app-state[data-state="gone"] { color: var(--color-text-tertiary); }
1020
+ </style>
1021
+ </head>
1022
+ <body>
1023
+ <div class="card">
1024
+ <div class="head">
1025
+ <div>
1026
+ <div class="title" id="host">—</div>
1027
+ <div class="sub" id="sub">Loading server details…</div>
1028
+ </div>
1029
+ <div class="chip" id="chip" data-state="idle">Unknown</div>
1030
+ </div>
1031
+
1032
+ <div class="facts" id="facts" hidden></div>
1033
+
1034
+ <div class="meters" id="meters" hidden>
1035
+ <div>
1036
+ <div class="meter-head">
1037
+ <span class="label">CPU</span>
1038
+ <span class="meter-val" id="cpuVal"></span>
1039
+ </div>
1040
+ <div class="track"><div class="fill" id="cpuFill"></div></div>
1041
+ </div>
1042
+ <div>
1043
+ <div class="meter-head">
1044
+ <span class="label">Memory</span>
1045
+ <span class="meter-val" id="memVal"></span>
1046
+ </div>
1047
+ <div class="track"><div class="fill" id="memFill"></div></div>
1048
+ </div>
1049
+ </div>
1050
+
1051
+ <div class="actions">
1052
+ <button class="btn" id="refresh" type="button">Refresh</button>
1053
+ <button class="btn" id="start" type="button" hidden>Start</button>
1054
+ <button class="btn" id="reboot" type="button" hidden>Reboot</button>
1055
+ <button class="btn" id="shutdown" type="button" hidden>Shut down</button>
1056
+ <span class="note" id="status"></span>
1057
+ </div>
1058
+
1059
+ <div>
1060
+ <button class="btn quiet" id="appsToggle" type="button">Show deployed apps</button>
1061
+ <div class="apps" id="apps" hidden style="margin-top:10px"></div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <script>
1066
+ // Impreza Host — the MCP Apps host bridge, shared by every panel.
1067
+ //
1068
+ // Substituted into each panel's BRIDGE marker by McpUi::read() and by
1069
+ // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
1070
+ // nothing from the network, so the only alternatives to substitution were three
1071
+ // copies of this file or a build step. One mechanism, one place the verifier has
1072
+ // to lint, and the panels stay readable.
1073
+ //
1074
+ // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
1075
+ // · No network. Every value arrives over this bridge.
1076
+ // · Never innerHTML. Tool values reach the DOM through textContent only.
1077
+ // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
1078
+ // an @import from a font service and its own applyHostFonts() injects it
1079
+ // verbatim, which on our panels is a third-party request identifying the
1080
+ // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
1081
+
1082
+ 'use strict';
1083
+
1084
+ var Bridge = (function () {
1085
+ var nextId = 1;
1086
+ var pending = Object.create(null);
1087
+ var handlers = Object.create(null);
1088
+ var hostCaps = {};
1089
+
1090
+ window.addEventListener('message', function (event) {
1091
+ var msg = event.data;
1092
+ if (!msg || msg.jsonrpc !== '2.0') { return; }
1093
+ if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
1094
+ var p = pending[msg.id];
1095
+ delete pending[msg.id];
1096
+ if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
1097
+ else { p.resolve(msg.result); }
1098
+ return;
1099
+ }
1100
+ if (msg.method && handlers[msg.method]) {
1101
+ // A malformed notification must not take the panel down with it.
1102
+ try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
1103
+ }
1104
+ });
1105
+
1106
+ function request(method, params) {
1107
+ var id = nextId++;
1108
+ return new Promise(function (resolve, reject) {
1109
+ pending[id] = { resolve: resolve, reject: reject };
1110
+ window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
1111
+ });
1112
+ }
1113
+
1114
+ function notify(method, params) {
1115
+ window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
1116
+ }
1117
+
1118
+ function on(method, fn) { handlers[method] = fn; }
1119
+
1120
+ /**
1121
+ * Call one of OUR tools through the host. The host decides whether to
1122
+ * forward it and may ask the viewer first — a panel never assumes consent.
1123
+ * Resolves with the tool's structuredContent when it has one, falling back to
1124
+ * parsing the text block for a host that only forwards \`content\`.
1125
+ */
1126
+ function callTool(name, args) {
1127
+ return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
1128
+ res = res || {};
1129
+ if (res.isError) {
1130
+ var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
1131
+ throw new Error(text);
1132
+ }
1133
+ return unwrap(res);
1134
+ });
1135
+ }
1136
+
1137
+ /** structuredContent if present, else the text block parsed as JSON. */
1138
+ function unwrap(payload) {
1139
+ if (!payload) { return null; }
1140
+ if (payload.structuredContent) { return payload.structuredContent; }
1141
+ var t = payload.content && payload.content[0] && payload.content[0].text;
1142
+ if (typeof t === 'string') {
1143
+ try { return JSON.parse(t); } catch (e) { return null; }
1144
+ }
1145
+ return null;
1146
+ }
1147
+
1148
+ function applyStyles(styles) {
1149
+ if (!styles || !styles.variables) { return; }
1150
+ Object.keys(styles.variables).forEach(function (k) {
1151
+ var v = styles.variables[k];
1152
+ // Only custom properties, and only plain values: a variable is otherwise
1153
+ // a place a host could smuggle CSS into our document.
1154
+ if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
1155
+ document.documentElement.style.setProperty(k, v);
1156
+ }
1157
+ });
1158
+ // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
1159
+ }
1160
+
1161
+ function applyTheme(theme) {
1162
+ if (theme === 'light' || theme === 'dark') {
1163
+ document.documentElement.style.colorScheme = theme;
1164
+ }
1165
+ }
1166
+
1167
+ var lastH = 0;
1168
+ function reportSize() {
1169
+ var h = Math.ceil(document.documentElement.scrollHeight);
1170
+ var w = Math.ceil(document.documentElement.scrollWidth);
1171
+ if (h === lastH) { return; }
1172
+ lastH = h;
1173
+ notify('ui/notifications/size-changed', { width: w, height: h });
1174
+ }
1175
+
1176
+ /**
1177
+ * Handshake, then hand the panel its data.
1178
+ *
1179
+ * onResult receives the tool result (unwrapped), onInput the tool arguments,
1180
+ * onFail a message when there is no bridge or the call was cancelled.
1181
+ */
1182
+ function start(opts) {
1183
+ opts = opts || {};
1184
+ var onResult = opts.onResult || function () {};
1185
+ var onInput = opts.onInput || function () {};
1186
+ var onFail = opts.onFail || function () {};
1187
+
1188
+ on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
1189
+ on('ui/notifications/tool-result', function (params) {
1190
+ if (params && params.isError) {
1191
+ var text = (params.content && params.content[0] && params.content[0].text) || '';
1192
+ onFail(text || 'The tool call failed.');
1193
+ return;
1194
+ }
1195
+ var data = unwrap(params);
1196
+ if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
1197
+ reportSize();
1198
+ });
1199
+ on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
1200
+ on('ui/notifications/host-context-changed', function (params) {
1201
+ if (!params) { return; }
1202
+ if (params.theme) { applyTheme(params.theme); }
1203
+ if (params.styles) { applyStyles(params.styles); }
1204
+ });
1205
+
1206
+ if (window.ResizeObserver) {
1207
+ new ResizeObserver(reportSize).observe(document.body);
1208
+ }
1209
+
1210
+ return request('ui/initialize', {
1211
+ protocolVersion: '2026-01-26',
1212
+ clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
1213
+ appCapabilities: {}
1214
+ }).then(function (result) {
1215
+ result = result || {};
1216
+ hostCaps = result.capabilities || {};
1217
+ var ctx = result.hostContext || {};
1218
+ applyTheme(ctx.theme);
1219
+ applyStyles(ctx.styles);
1220
+ notify('ui/notifications/initialized', {});
1221
+ reportSize();
1222
+ return { capabilities: hostCaps, hostContext: ctx };
1223
+ }, function () {
1224
+ // Standalone, with no host on the other end. Saying so beats a spinner
1225
+ // that never resolves.
1226
+ onFail('This panel needs an MCP host to load its data.');
1227
+ throw new Error('no host bridge');
1228
+ });
1229
+ }
1230
+
1231
+ /** Ask the host to open a URL. Only offered when it says it can. */
1232
+ function openLink(url) {
1233
+ return request('ui/open-link', { url: String(url) });
1234
+ }
1235
+
1236
+ function canOpenLinks() { return !!hostCaps.openLinks; }
1237
+
1238
+ /** Copy text, with a fallback because permissions are never guaranteed. */
1239
+ function copy(text, button, okLabel, failLabel) {
1240
+ var done = function (ok) {
1241
+ var original = button.dataset.label || button.textContent;
1242
+ button.dataset.label = original;
1243
+ button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
1244
+ setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
1245
+ };
1246
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1247
+ navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
1248
+ return;
1249
+ }
1250
+ done(false);
1251
+ }
1252
+
1253
+ return {
1254
+ request: request,
1255
+ notify: notify,
1256
+ on: on,
1257
+ callTool: callTool,
1258
+ unwrap: unwrap,
1259
+ start: start,
1260
+ reportSize: reportSize,
1261
+ openLink: openLink,
1262
+ canOpenLinks: canOpenLinks,
1263
+ copy: copy
1264
+ };
1265
+ })();
1266
+
1267
+ /** Shorthand every panel uses. */
1268
+ function el(id) { return document.getElementById(id); }
1269
+
1270
+
1271
+ (function () {
1272
+ 'use strict';
1273
+
1274
+ var serviceId = null;
1275
+ var powerState = '';
1276
+ var busy = false;
1277
+ var appsLoaded = false;
1278
+
1279
+ // ── formatting ──────────────────────────────────────────────────────────
1280
+ function gib(bytes) {
1281
+ var n = Number(bytes);
1282
+ if (!isFinite(n) || n <= 0) { return null; }
1283
+ return n / 1073741824;
1284
+ }
1285
+
1286
+ function uptime(seconds) {
1287
+ var s = Number(seconds);
1288
+ if (!isFinite(s) || s <= 0) { return null; }
1289
+ var d = Math.floor(s / 86400);
1290
+ var h = Math.floor((s % 86400) / 3600);
1291
+ var m = Math.floor((s % 3600) / 60);
1292
+ if (d > 0) { return d + 'd ' + h + 'h'; }
1293
+ if (h > 0) { return h + 'h ' + m + 'm'; }
1294
+ return m + 'm';
1295
+ }
1296
+
1297
+ function load(pct) {
1298
+ if (pct >= 90) { return 'full'; }
1299
+ if (pct >= 70) { return 'high'; }
1300
+ return 'normal';
1301
+ }
1302
+
1303
+ function setMeter(fillEl, valEl, pct, text) {
1304
+ var p = Math.max(0, Math.min(100, Number(pct) || 0));
1305
+ fillEl.style.width = p.toFixed(1) + '%';
1306
+ fillEl.dataset.load = load(p);
1307
+ valEl.textContent = text;
1308
+ }
1309
+
1310
+ // ── rendering ───────────────────────────────────────────────────────────
1311
+ function setPower(state) {
1312
+ powerState = String(state || '').toLowerCase();
1313
+ var chip = el('chip');
1314
+ if (powerState === 'running') {
1315
+ chip.dataset.state = 'ok';
1316
+ chip.textContent = 'Running';
1317
+ } else if (powerState === 'stopped') {
1318
+ chip.dataset.state = 'off';
1319
+ chip.textContent = 'Stopped';
1320
+ } else if (powerState === '') {
1321
+ chip.dataset.state = 'idle';
1322
+ chip.textContent = 'Unknown';
1323
+ } else {
1324
+ chip.dataset.state = 'pending';
1325
+ chip.textContent = powerState;
1326
+ }
1327
+
1328
+ // Offer only what the current state makes sense for.
1329
+ var running = powerState === 'running';
1330
+ el('start').hidden = running || powerState === '';
1331
+ el('reboot').hidden = !running;
1332
+ el('shutdown').hidden = !running;
1333
+ disarmAll();
1334
+ }
1335
+
1336
+ function renderStatus(d) {
1337
+ if (!d || typeof d !== 'object') { return; }
1338
+ setPower(d.power_state);
1339
+
1340
+ var usedGiB = gib(d.memory_used);
1341
+ var totalGiB = gib(d.memory_total);
1342
+ var memPct = (usedGiB !== null && totalGiB) ? (usedGiB / totalGiB) * 100 : 0;
1343
+ var cpuPct = Number(d.cpu_usage);
1344
+ if (!isFinite(cpuPct)) { cpuPct = 0; }
1345
+ // The upstream reports CPU as a percentage already; a 0–1 fraction would be
1346
+ // a different contract, and mis-reading it would draw a flat bar forever.
1347
+ if (cpuPct > 0 && cpuPct <= 1 && d.cpu_usage !== 1) { cpuPct = cpuPct * 100; }
1348
+
1349
+ el('meters').hidden = false;
1350
+ setMeter(el('cpuFill'), el('cpuVal'), cpuPct, cpuPct.toFixed(1) + '% of 100%');
1351
+ setMeter(
1352
+ el('memFill'), el('memVal'), memPct,
1353
+ (usedGiB !== null && totalGiB)
1354
+ ? usedGiB.toFixed(2) + ' of ' + totalGiB.toFixed(2) + ' GiB · ' + memPct.toFixed(0) + '%'
1355
+ : 'not reported'
1356
+ );
1357
+
1358
+ var up = uptime(d.uptime);
1359
+ el('sub').textContent = up ? ('Up ' + up) : '';
1360
+ Bridge.reportSize();
1361
+ }
1362
+
1363
+ function renderIdentity(info) {
1364
+ if (!info || typeof info !== 'object') { return; }
1365
+ if (info.hostname) { el('host').textContent = String(info.hostname); }
1366
+
1367
+ var res = info.resources || {};
1368
+ var bits = [];
1369
+ var push = function (label, value) {
1370
+ if (value === null || value === undefined || value === '') { return; }
1371
+ bits.push([label, String(value)]);
1372
+ };
1373
+ push('Node', info.node);
1374
+ push('vCPU', res.cores);
1375
+ push('RAM', res.memory_mb ? (Math.round(res.memory_mb / 1024 * 10) / 10) + ' GiB' : null);
1376
+ push('Disk', res.disk_gb ? res.disk_gb + ' GB' : null);
1377
+ var ips = Array.isArray(info.ips) ? info.ips.map(function (i) { return String(i.ip || ''); }).filter(Boolean) : [];
1378
+ push('IP', ips.join(', '));
1379
+ if (info.os) { push('OS', info.os); }
1380
+
1381
+ var box = el('facts');
1382
+ box.textContent = '';
1383
+ bits.forEach(function (pair) {
1384
+ var span = document.createElement('span');
1385
+ span.appendChild(document.createTextNode(pair[0] + ' '));
1386
+ var b = document.createElement('b');
1387
+ b.textContent = pair[1];
1388
+ span.appendChild(b);
1389
+ box.appendChild(span);
1390
+ });
1391
+ box.hidden = bits.length === 0;
1392
+ Bridge.reportSize();
1393
+ }
1394
+
1395
+ function appState(status) {
1396
+ var s = String(status || '').toLowerCase();
1397
+ if (s === 'running') { return ['ok', s]; }
1398
+ if (s === 'failed') { return ['bad', s]; }
1399
+ if (s === 'uninstalled') { return ['gone', s]; }
1400
+ return ['work', s || 'unknown'];
1401
+ }
1402
+
1403
+ function renderApps(list) {
1404
+ var box = el('apps');
1405
+ box.textContent = '';
1406
+ var rows = Array.isArray(list) ? list : [];
1407
+ if (rows.length === 0) {
1408
+ var none = document.createElement('div');
1409
+ none.className = 'note';
1410
+ none.textContent = 'No apps deployed on this server yet.';
1411
+ box.appendChild(none);
1412
+ Bridge.reportSize();
1413
+ return;
1414
+ }
1415
+ rows.forEach(function (d) {
1416
+ var row = document.createElement('div');
1417
+ row.className = 'app';
1418
+
1419
+ var left = document.createElement('div');
1420
+ var name = document.createElement('div');
1421
+ name.className = 'app-name';
1422
+ name.textContent = String(d.app_name || d.name || d.id || '?');
1423
+ left.appendChild(name);
1424
+ if (d.domain) {
1425
+ var host = document.createElement('div');
1426
+ host.className = 'app-host';
1427
+ host.textContent = String(d.domain).replace(/^https?:\\/\\//, '');
1428
+ left.appendChild(host);
1429
+ }
1430
+ row.appendChild(left);
1431
+
1432
+ var pair = appState(d.status);
1433
+ var st = document.createElement('div');
1434
+ st.className = 'app-state';
1435
+ st.dataset.state = pair[0];
1436
+ st.textContent = pair[1];
1437
+ row.appendChild(st);
1438
+
1439
+ box.appendChild(row);
1440
+ });
1441
+ Bridge.reportSize();
1442
+ }
1443
+
1444
+ function fail(text) {
1445
+ var chip = el('chip');
1446
+ chip.dataset.state = 'error';
1447
+ chip.textContent = 'Unavailable';
1448
+ el('sub').textContent = '';
1449
+ var s = el('status');
1450
+ s.className = 'note warn';
1451
+ s.textContent = String(text || 'Could not load this server.');
1452
+ Bridge.reportSize();
1453
+ }
1454
+
1455
+ function say(text, bad) {
1456
+ var s = el('status');
1457
+ s.className = bad ? 'note warn' : 'note';
1458
+ s.textContent = text || '';
1459
+ Bridge.reportSize();
1460
+ }
1461
+
1462
+ // ── the arm-then-fire pattern for the disruptive actions ────────────────
1463
+ function disarmAll() {
1464
+ ['reboot', 'shutdown'].forEach(function (id) {
1465
+ var b = el(id);
1466
+ b.dataset.armed = '0';
1467
+ b.textContent = b.dataset.base || b.textContent;
1468
+ });
1469
+ }
1470
+
1471
+ function wirePower(id, action, disruptive) {
1472
+ var btn = el(id);
1473
+ btn.dataset.base = btn.textContent;
1474
+ btn.addEventListener('click', function () {
1475
+ if (busy || serviceId === null) { return; }
1476
+ if (disruptive && btn.dataset.armed !== '1') {
1477
+ disarmAll();
1478
+ btn.dataset.armed = '1';
1479
+ btn.textContent = 'Confirm ' + btn.dataset.base.toLowerCase();
1480
+ say('This interrupts everything running on the server. Click again to go ahead.');
1481
+ return;
1482
+ }
1483
+ disarmAll();
1484
+ busy = true;
1485
+ btn.disabled = true;
1486
+ say(btn.dataset.base + ' requested…');
1487
+ Bridge.callTool('impreza_vps_power', { service_id: serviceId, action: action })
1488
+ .then(function () {
1489
+ say('Accepted. The state below refreshes as the server catches up.');
1490
+ setTimeout(refreshStatus, 4000);
1491
+ })
1492
+ .catch(function (e) { say(e.message || 'The host declined that action.', true); })
1493
+ .then(function () { busy = false; btn.disabled = false; Bridge.reportSize(); });
1494
+ });
1495
+ }
1496
+
1497
+ // ── server calls ────────────────────────────────────────────────────────
1498
+ function refreshStatus() {
1499
+ if (serviceId === null) { return Promise.resolve(); }
1500
+ return Bridge.callTool('impreza_vps_status', { service_id: serviceId })
1501
+ .then(renderStatus)
1502
+ .catch(function (e) { say(e.message || 'Could not refresh.', true); });
1503
+ }
1504
+
1505
+ function loadIdentity() {
1506
+ if (serviceId === null) { return; }
1507
+ // One enrichment call, for the facts that do not change.
1508
+ Bridge.callTool('impreza_api_call', { path: '/vps/proxmox/' + serviceId })
1509
+ .then(renderIdentity)
1510
+ .catch(function () { /* the card is still useful without the hardware strip */ });
1511
+ }
1512
+
1513
+ function loadApps() {
1514
+ // Two calls, and only on demand: list_servers maps this service to its
1515
+ // agent, and only then can deployments be filtered to THIS machine.
1516
+ // Showing another server's apps would be worse than showing none.
1517
+ return Bridge.callTool('impreza_list_servers', {})
1518
+ .then(function (d) {
1519
+ var servers = (d && d.servers) || [];
1520
+ var mine = servers.filter(function (s) { return String(s.service_id) === String(serviceId); })[0];
1521
+ if (!mine || !mine.agent_id) {
1522
+ renderApps([]);
1523
+ return;
1524
+ }
1525
+ return Bridge.callTool('impreza_list_deployments', { agent_id: mine.agent_id })
1526
+ .then(function (r) { renderApps((r && r.deployments) || []); });
1527
+ })
1528
+ .catch(function (e) { say(e.message || 'Could not list apps.', true); });
1529
+ }
1530
+
1531
+ // ── wiring ──────────────────────────────────────────────────────────────
1532
+ el('refresh').addEventListener('click', function () {
1533
+ if (busy) { return; }
1534
+ say('Refreshing…');
1535
+ refreshStatus().then(function () { say(''); });
1536
+ });
1537
+
1538
+ wirePower('start', 'start', false);
1539
+ wirePower('reboot', 'reboot', true);
1540
+ wirePower('shutdown', 'shutdown', true);
1541
+
1542
+ el('appsToggle').addEventListener('click', function () {
1543
+ var box = el('apps');
1544
+ if (box.hidden) {
1545
+ box.hidden = false;
1546
+ this.textContent = 'Hide deployed apps';
1547
+ if (!appsLoaded) { appsLoaded = true; loadApps(); }
1548
+ } else {
1549
+ box.hidden = true;
1550
+ this.textContent = 'Show deployed apps';
1551
+ }
1552
+ Bridge.reportSize();
1553
+ });
1554
+
1555
+ Bridge.start({
1556
+ name: 'impreza-server-card',
1557
+ onInput: function (args) {
1558
+ // The status result carries no service_id; the tool ARGUMENTS do, and
1559
+ // every later call this panel makes needs it.
1560
+ var raw = args && args.service_id;
1561
+ if (raw !== undefined && raw !== null && String(raw) !== '') {
1562
+ serviceId = Number(raw);
1563
+ el('sub').textContent = 'Service #' + serviceId;
1564
+ loadIdentity();
1565
+ }
1566
+ },
1567
+ onResult: renderStatus,
1568
+ onFail: fail
1569
+ });
1570
+ })();
1571
+ </script>
1572
+ </body>
1573
+ </html>
1574
1574
  `;
1575
1575
  /** md5 of the canonical lib/ui/server-card.html, LF-normalised. */
1576
1576
  export const SERVER_CARD_HTML_MD5 = '86ae56f14b80cb4af14e7bc54bf6c92a';
1577
- export const DEPLOY_WIZARD_HTML = `<!DOCTYPE html>
1578
- <html lang="en">
1579
- <head>
1580
- <meta charset="utf-8">
1581
-
1582
- <title>Deploy an app</title>
1583
- <!--
1584
- Impreza Host — MCP Apps view for impreza_list_apps.
1585
-
1586
- Browsing a catalogue, filling three fields and watching a deploy is the
1587
- clearest case in this product for a form over a conversation: the questions
1588
- are fixed, the answers are short, and the progress is a state machine.
1589
-
1590
- Three decisions worth knowing:
1591
-
1592
- · The form offers ONLY what the selected app declares it supports. The
1593
- catalogue carries \`supports.custom_domain\` and \`supports.onion\` per app, so
1594
- a domain field on an app that cannot take one, or an onion toggle on an app
1595
- with no hidden-service support, would be a promise the deploy then breaks.
1596
- The requirements line is drawn from the app's own numbers for the same
1597
- reason.
1598
-
1599
- · No app icons. The catalogue gives \`icon_url\`, and fetching it would be a
1600
- network request from a panel whose declared CSP has none — so the tiles use
1601
- a monogram from the app's own name. The constraint shaped the design rather
1602
- than being worked around.
1603
-
1604
- · Progress is read from the deployment's own status column via
1605
- impreza_list_deployments, not from a second progress model. That works
1606
- whether or not the host speaks the Tasks extension: a host that does gets a
1607
- task handle back from the deploy, one that does not gets the deployment
1608
- JSON, and polling the list answers both.
1609
-
1610
- This panel deliberately calls no confirmation-gated tool. Those exist because
1611
- intent must be proven across two SEPARATE calls, and a view doing both halves
1612
- on one click would defeat the gate rather than satisfy it.
1613
- -->
1614
- <style>
1615
- /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
1616
- *
1617
- * Substituted into each panel's TOKENS marker by McpUi::read() and by
1618
- * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
1619
- * nothing from the network, so the choice was one substitution or three copies.
1620
- * Keeping it in one file is also what makes our panels read as one family
1621
- * rather than three pages that happen to ship together.
1622
- *
1623
- * Host-provided variables win. These fallbacks are what a panel looks like when
1624
- * a host sends none — which the spec warns is common, along with partial sets.
1625
- * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
1626
- */
1627
-
1628
- :root {
1629
- color-scheme: light dark;
1630
-
1631
- --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
1632
- --color-background-secondary: light-dark(#f4f3ef, #232320);
1633
- --color-background-tertiary: light-dark(#eceae4, #2b2a26);
1634
- --color-text-primary: light-dark(#1a1a17, #f2f1ed);
1635
- --color-text-secondary: light-dark(#6e6d66, #a5a49c);
1636
- --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
1637
- --color-text-warning: light-dark(#8a5a00, #e0a83a);
1638
- --color-text-success: light-dark(#1f6b3a, #5fbf83);
1639
- --color-text-danger: light-dark(#9a2b21, #f08a7e);
1640
- --color-border-primary: light-dark(#e5e4de, #33302b);
1641
- --color-border-secondary: light-dark(#efeee9, #2a2825);
1642
-
1643
- --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
1644
- --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1645
-
1646
- --border-radius-sm: 6px;
1647
- --border-radius-md: 10px;
1648
- --border-radius-full: 999px;
1649
- }
1650
-
1651
- * { box-sizing: border-box; }
1652
-
1653
- body {
1654
- margin: 0;
1655
- background: var(--color-background-primary);
1656
- color: var(--color-text-primary);
1657
- font-family: var(--font-sans);
1658
- font-size: 14px;
1659
- line-height: 1.45;
1660
- -webkit-font-smoothing: antialiased;
1661
- }
1662
-
1663
- .card {
1664
- display: flex;
1665
- flex-direction: column;
1666
- gap: 14px;
1667
- padding: 16px;
1668
- max-width: 560px;
1669
- }
1670
-
1671
- /* ── header ───────────────────────────────────────────────────────────── */
1672
- .head {
1673
- display: flex;
1674
- align-items: flex-start;
1675
- justify-content: space-between;
1676
- gap: 12px;
1677
- }
1678
- .title {
1679
- font-family: var(--font-mono);
1680
- font-size: 18px;
1681
- letter-spacing: -0.01em;
1682
- overflow-wrap: anywhere;
1683
- }
1684
- .total {
1685
- font-family: var(--font-mono);
1686
- font-size: 22px;
1687
- font-variant-numeric: tabular-nums;
1688
- letter-spacing: -0.01em;
1689
- }
1690
- .sub {
1691
- margin-top: 2px;
1692
- color: var(--color-text-secondary);
1693
- font-size: 12px;
1694
- }
1695
-
1696
- /* State, and state only — the one place colour is allowed to catch the eye. */
1697
- .chip {
1698
- flex: none;
1699
- padding: 3px 10px;
1700
- border: 1px solid var(--color-border-primary);
1701
- border-radius: var(--border-radius-full);
1702
- font-size: 11px;
1703
- font-weight: 600;
1704
- letter-spacing: 0.04em;
1705
- text-transform: uppercase;
1706
- white-space: nowrap;
1707
- }
1708
- .chip[data-state="pending"] { color: var(--color-text-warning); }
1709
- .chip[data-state="paid"],
1710
- .chip[data-state="ok"] { color: var(--color-text-success); }
1711
- .chip[data-state="error"],
1712
- .chip[data-state="off"] { color: var(--color-text-danger); }
1713
- .chip[data-state="idle"] { color: var(--color-text-secondary); }
1714
-
1715
- /* ── labelled value blocks ────────────────────────────────────────────── */
1716
- .field {
1717
- border: 1px solid var(--color-border-primary);
1718
- border-radius: var(--border-radius-md);
1719
- background: var(--color-background-secondary);
1720
- padding: 10px 12px;
1721
- }
1722
- .field-head {
1723
- display: flex;
1724
- align-items: baseline;
1725
- justify-content: space-between;
1726
- gap: 8px;
1727
- margin-bottom: 6px;
1728
- }
1729
- .label {
1730
- color: var(--color-text-tertiary);
1731
- font-size: 10px;
1732
- font-weight: 700;
1733
- letter-spacing: 0.08em;
1734
- text-transform: uppercase;
1735
- }
1736
- .value {
1737
- font-family: var(--font-mono);
1738
- font-size: 15px;
1739
- font-variant-numeric: tabular-nums;
1740
- overflow-wrap: anywhere;
1741
- user-select: all;
1742
- }
1743
-
1744
- /* ── controls ─────────────────────────────────────────────────────────── */
1745
- .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1746
- .btn {
1747
- padding: 6px 13px;
1748
- border: 1px solid var(--color-border-primary);
1749
- border-radius: var(--border-radius-sm);
1750
- background: var(--color-background-tertiary);
1751
- color: var(--color-text-primary);
1752
- font: inherit;
1753
- font-size: 12px;
1754
- font-weight: 600;
1755
- cursor: pointer;
1756
- }
1757
- .btn:disabled { opacity: 0.5; cursor: default; }
1758
- .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1759
- /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
1760
- .btn[data-armed="1"] {
1761
- color: var(--color-text-danger);
1762
- border-color: var(--color-text-danger);
1763
- }
1764
- .btn.quiet {
1765
- background: transparent;
1766
- color: var(--color-text-secondary);
1767
- }
1768
- .btn.quiet:hover { color: var(--color-text-primary); }
1769
- .copy {
1770
- flex: none;
1771
- padding: 3px 9px;
1772
- border: 1px solid var(--color-border-primary);
1773
- border-radius: var(--border-radius-sm);
1774
- background: var(--color-background-primary);
1775
- color: var(--color-text-secondary);
1776
- font: inherit;
1777
- font-size: 11px;
1778
- font-weight: 600;
1779
- cursor: pointer;
1780
- }
1781
- .copy:hover { color: var(--color-text-primary); }
1782
- .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1783
-
1784
- input[type="text"], select {
1785
- width: 100%;
1786
- padding: 6px 9px;
1787
- border: 1px solid var(--color-border-primary);
1788
- border-radius: var(--border-radius-sm);
1789
- background: var(--color-background-primary);
1790
- color: var(--color-text-primary);
1791
- font: inherit;
1792
- font-size: 13px;
1793
- }
1794
- input[type="text"] { font-family: var(--font-mono); }
1795
- input:focus-visible, select:focus-visible {
1796
- outline: 2px solid var(--color-text-secondary);
1797
- outline-offset: 1px;
1798
- }
1799
- .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
1800
- .check input { margin: 0; }
1801
-
1802
- /* ── notes ────────────────────────────────────────────────────────────── */
1803
- .note { color: var(--color-text-secondary); font-size: 11.5px; }
1804
- .note strong { color: var(--color-text-primary); font-weight: 600; }
1805
- .warn { color: var(--color-text-danger); }
1806
- .rest { color: var(--color-text-secondary); font-size: 13px; }
1807
-
1808
- [hidden] { display: none !important; }
1809
-
1810
- @media (prefers-reduced-motion: no-preference) {
1811
- .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
1812
- }
1813
-
1814
-
1815
- /* ── search + catalogue ───────────────────────────────────────────────── */
1816
- .tiles {
1817
- display: grid;
1818
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1819
- gap: 6px;
1820
- max-height: 260px;
1821
- overflow-y: auto;
1822
- }
1823
- .tile {
1824
- display: flex;
1825
- align-items: center;
1826
- gap: 9px;
1827
- padding: 8px 9px;
1828
- border: 1px solid var(--color-border-secondary);
1829
- border-radius: var(--border-radius-sm);
1830
- background: transparent;
1831
- color: inherit;
1832
- font: inherit;
1833
- text-align: left;
1834
- cursor: pointer;
1835
- }
1836
- .tile:hover { border-color: var(--color-border-primary); }
1837
- .tile[aria-pressed="true"] {
1838
- border-color: var(--color-text-secondary);
1839
- background: var(--color-background-secondary);
1840
- }
1841
- .tile:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1842
- /* No icon_url fetch — a monogram from the app's own name instead. */
1843
- .mono {
1844
- flex: none;
1845
- width: 26px;
1846
- height: 26px;
1847
- display: grid;
1848
- place-items: center;
1849
- border-radius: var(--border-radius-sm);
1850
- background: var(--color-background-tertiary);
1851
- font-family: var(--font-mono);
1852
- font-size: 12px;
1853
- font-weight: 700;
1854
- }
1855
- .tile-name { font-size: 12.5px; font-weight: 600; line-height: 1.25; }
1856
- .tile-ver {
1857
- color: var(--color-text-tertiary);
1858
- font-family: var(--font-mono);
1859
- font-size: 10.5px;
1860
- }
1861
-
1862
- /* ── the form ─────────────────────────────────────────────────────────── */
1863
- .form { display: flex; flex-direction: column; gap: 10px; }
1864
- .row { display: flex; flex-direction: column; gap: 4px; }
1865
- .needs {
1866
- color: var(--color-text-secondary);
1867
- font-family: var(--font-mono);
1868
- font-size: 11.5px;
1869
- }
1870
-
1871
- /* ── progress ─────────────────────────────────────────────────────────── */
1872
- .steps { display: flex; flex-direction: column; gap: 5px; }
1873
- .step {
1874
- display: flex;
1875
- align-items: baseline;
1876
- gap: 8px;
1877
- font-size: 12.5px;
1878
- color: var(--color-text-secondary);
1879
- }
1880
- .step b {
1881
- font-family: var(--font-mono);
1882
- font-size: 10px;
1883
- font-weight: 700;
1884
- letter-spacing: 0.06em;
1885
- text-transform: uppercase;
1886
- min-width: 4.5em;
1887
- }
1888
- .step[data-kind="ok"] b { color: var(--color-text-success); }
1889
- .step[data-kind="work"] b { color: var(--color-text-warning); }
1890
- .step[data-kind="bad"] b { color: var(--color-text-danger); }
1891
- </style>
1892
- </head>
1893
- <body>
1894
- <div class="card">
1895
- <div class="head">
1896
- <div>
1897
- <div class="title" id="title">Deploy an app</div>
1898
- <div class="sub" id="sub">Loading the catalogue…</div>
1899
- </div>
1900
- <div class="chip" id="chip" data-state="idle" hidden>Idle</div>
1901
- </div>
1902
-
1903
- <!-- ── pick ────────────────────────────────────────────────────────── -->
1904
- <div id="pick" hidden>
1905
- <div class="row" style="margin-bottom:8px">
1906
- <label class="label" for="search">Search the catalogue</label>
1907
- <input type="text" id="search" autocomplete="off" spellcheck="false" placeholder="name, category or tag">
1908
- </div>
1909
- <div class="tiles" id="tiles" role="listbox" aria-label="Catalogue apps"></div>
1910
- <div class="note" id="none" hidden style="margin-top:8px">Nothing in the catalogue matches that.</div>
1911
- </div>
1912
-
1913
- <!-- ── configure ───────────────────────────────────────────────────── -->
1914
- <div class="form" id="form" hidden>
1915
- <div class="needs" id="needs"></div>
1916
- <div class="row">
1917
- <label class="label" for="agent">Deploy to</label>
1918
- <select id="agent"></select>
1919
- </div>
1920
- <div class="row" id="domainRow">
1921
- <label class="label" for="domain">Domain <span style="text-transform:none;letter-spacing:0">— leave blank for a free *.imprezaapps.com hostname</span></label>
1922
- <input type="text" id="domain" autocomplete="off" spellcheck="false" placeholder="app.example.com">
1923
- </div>
1924
- <label class="check" id="onionRow">
1925
- <input type="checkbox" id="onion">
1926
- <span>Also publish a Tor hidden service (.onion) mirror</span>
1927
- </label>
1928
- <div class="actions">
1929
- <button class="btn" id="deploy" type="button">Deploy</button>
1930
- <button class="btn quiet" id="back" type="button">Pick another app</button>
1931
- <span class="note" id="status"></span>
1932
- </div>
1933
- </div>
1934
-
1935
- <!-- ── watch ───────────────────────────────────────────────────────── -->
1936
- <div id="progress" hidden>
1937
- <div class="steps" id="steps"></div>
1938
- <div class="field" id="result" hidden style="margin-top:10px">
1939
- <div class="field-head">
1940
- <span class="label">Live at</span>
1941
- <button class="copy" id="copyUrl" type="button">Copy</button>
1942
- </div>
1943
- <div class="value" id="url"></div>
1944
- </div>
1945
- <div class="actions" style="margin-top:10px">
1946
- <button class="btn" id="open" type="button" hidden>Open</button>
1947
- <button class="btn quiet" id="again" type="button" hidden>Deploy another</button>
1948
- </div>
1949
- </div>
1950
- </div>
1951
-
1952
- <script>
1953
- // Impreza Host — the MCP Apps host bridge, shared by every panel.
1954
- //
1955
- // Substituted into each panel's BRIDGE marker by McpUi::read() and by
1956
- // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
1957
- // nothing from the network, so the only alternatives to substitution were three
1958
- // copies of this file or a build step. One mechanism, one place the verifier has
1959
- // to lint, and the panels stay readable.
1960
- //
1961
- // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
1962
- // · No network. Every value arrives over this bridge.
1963
- // · Never innerHTML. Tool values reach the DOM through textContent only.
1964
- // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
1965
- // an @import from a font service and its own applyHostFonts() injects it
1966
- // verbatim, which on our panels is a third-party request identifying the
1967
- // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
1968
-
1969
- 'use strict';
1970
-
1971
- var Bridge = (function () {
1972
- var nextId = 1;
1973
- var pending = Object.create(null);
1974
- var handlers = Object.create(null);
1975
- var hostCaps = {};
1976
-
1977
- window.addEventListener('message', function (event) {
1978
- var msg = event.data;
1979
- if (!msg || msg.jsonrpc !== '2.0') { return; }
1980
- if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
1981
- var p = pending[msg.id];
1982
- delete pending[msg.id];
1983
- if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
1984
- else { p.resolve(msg.result); }
1985
- return;
1986
- }
1987
- if (msg.method && handlers[msg.method]) {
1988
- // A malformed notification must not take the panel down with it.
1989
- try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
1990
- }
1991
- });
1992
-
1993
- function request(method, params) {
1994
- var id = nextId++;
1995
- return new Promise(function (resolve, reject) {
1996
- pending[id] = { resolve: resolve, reject: reject };
1997
- window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
1998
- });
1999
- }
2000
-
2001
- function notify(method, params) {
2002
- window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
2003
- }
2004
-
2005
- function on(method, fn) { handlers[method] = fn; }
2006
-
2007
- /**
2008
- * Call one of OUR tools through the host. The host decides whether to
2009
- * forward it and may ask the viewer first — a panel never assumes consent.
2010
- * Resolves with the tool's structuredContent when it has one, falling back to
2011
- * parsing the text block for a host that only forwards \`content\`.
2012
- */
2013
- function callTool(name, args) {
2014
- return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
2015
- res = res || {};
2016
- if (res.isError) {
2017
- var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
2018
- throw new Error(text);
2019
- }
2020
- return unwrap(res);
2021
- });
2022
- }
2023
-
2024
- /** structuredContent if present, else the text block parsed as JSON. */
2025
- function unwrap(payload) {
2026
- if (!payload) { return null; }
2027
- if (payload.structuredContent) { return payload.structuredContent; }
2028
- var t = payload.content && payload.content[0] && payload.content[0].text;
2029
- if (typeof t === 'string') {
2030
- try { return JSON.parse(t); } catch (e) { return null; }
2031
- }
2032
- return null;
2033
- }
2034
-
2035
- function applyStyles(styles) {
2036
- if (!styles || !styles.variables) { return; }
2037
- Object.keys(styles.variables).forEach(function (k) {
2038
- var v = styles.variables[k];
2039
- // Only custom properties, and only plain values: a variable is otherwise
2040
- // a place a host could smuggle CSS into our document.
2041
- if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
2042
- document.documentElement.style.setProperty(k, v);
2043
- }
2044
- });
2045
- // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
2046
- }
2047
-
2048
- function applyTheme(theme) {
2049
- if (theme === 'light' || theme === 'dark') {
2050
- document.documentElement.style.colorScheme = theme;
2051
- }
2052
- }
2053
-
2054
- var lastH = 0;
2055
- function reportSize() {
2056
- var h = Math.ceil(document.documentElement.scrollHeight);
2057
- var w = Math.ceil(document.documentElement.scrollWidth);
2058
- if (h === lastH) { return; }
2059
- lastH = h;
2060
- notify('ui/notifications/size-changed', { width: w, height: h });
2061
- }
2062
-
2063
- /**
2064
- * Handshake, then hand the panel its data.
2065
- *
2066
- * onResult receives the tool result (unwrapped), onInput the tool arguments,
2067
- * onFail a message when there is no bridge or the call was cancelled.
2068
- */
2069
- function start(opts) {
2070
- opts = opts || {};
2071
- var onResult = opts.onResult || function () {};
2072
- var onInput = opts.onInput || function () {};
2073
- var onFail = opts.onFail || function () {};
2074
-
2075
- on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
2076
- on('ui/notifications/tool-result', function (params) {
2077
- if (params && params.isError) {
2078
- var text = (params.content && params.content[0] && params.content[0].text) || '';
2079
- onFail(text || 'The tool call failed.');
2080
- return;
2081
- }
2082
- var data = unwrap(params);
2083
- if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
2084
- reportSize();
2085
- });
2086
- on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
2087
- on('ui/notifications/host-context-changed', function (params) {
2088
- if (!params) { return; }
2089
- if (params.theme) { applyTheme(params.theme); }
2090
- if (params.styles) { applyStyles(params.styles); }
2091
- });
2092
-
2093
- if (window.ResizeObserver) {
2094
- new ResizeObserver(reportSize).observe(document.body);
2095
- }
2096
-
2097
- return request('ui/initialize', {
2098
- protocolVersion: '2026-01-26',
2099
- clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
2100
- appCapabilities: {}
2101
- }).then(function (result) {
2102
- result = result || {};
2103
- hostCaps = result.capabilities || {};
2104
- var ctx = result.hostContext || {};
2105
- applyTheme(ctx.theme);
2106
- applyStyles(ctx.styles);
2107
- notify('ui/notifications/initialized', {});
2108
- reportSize();
2109
- return { capabilities: hostCaps, hostContext: ctx };
2110
- }, function () {
2111
- // Standalone, with no host on the other end. Saying so beats a spinner
2112
- // that never resolves.
2113
- onFail('This panel needs an MCP host to load its data.');
2114
- throw new Error('no host bridge');
2115
- });
2116
- }
2117
-
2118
- /** Ask the host to open a URL. Only offered when it says it can. */
2119
- function openLink(url) {
2120
- return request('ui/open-link', { url: String(url) });
2121
- }
2122
-
2123
- function canOpenLinks() { return !!hostCaps.openLinks; }
2124
-
2125
- /** Copy text, with a fallback because permissions are never guaranteed. */
2126
- function copy(text, button, okLabel, failLabel) {
2127
- var done = function (ok) {
2128
- var original = button.dataset.label || button.textContent;
2129
- button.dataset.label = original;
2130
- button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
2131
- setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
2132
- };
2133
- if (navigator.clipboard && navigator.clipboard.writeText) {
2134
- navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
2135
- return;
2136
- }
2137
- done(false);
2138
- }
2139
-
2140
- return {
2141
- request: request,
2142
- notify: notify,
2143
- on: on,
2144
- callTool: callTool,
2145
- unwrap: unwrap,
2146
- start: start,
2147
- reportSize: reportSize,
2148
- openLink: openLink,
2149
- canOpenLinks: canOpenLinks,
2150
- copy: copy
2151
- };
2152
- })();
2153
-
2154
- /** Shorthand every panel uses. */
2155
- function el(id) { return document.getElementById(id); }
2156
-
2157
-
2158
- (function () {
2159
- 'use strict';
2160
-
2161
- var apps = [];
2162
- var servers = [];
2163
- var picked = null;
2164
- var busy = false;
2165
- var watchTimer = null;
2166
- var watchTicks = 0;
2167
- var deploymentId = null;
2168
-
2169
- // ── helpers ─────────────────────────────────────────────────────────────
2170
- function monogram(name) {
2171
- var s = String(name || '?').replace(/[^A-Za-z0-9]/g, '');
2172
- return (s.slice(0, 2) || '?').toUpperCase();
2173
- }
2174
-
2175
- function say(text, bad) {
2176
- var s = el('status');
2177
- s.className = bad ? 'note warn' : 'note';
2178
- s.textContent = text || '';
2179
- Bridge.reportSize();
2180
- }
2181
-
2182
- function setChip(state, text) {
2183
- var chip = el('chip');
2184
- chip.hidden = false;
2185
- chip.dataset.state = state;
2186
- chip.textContent = text;
2187
- }
2188
-
2189
- function step(kind, tag, text) {
2190
- var row = document.createElement('div');
2191
- row.className = 'step';
2192
- row.dataset.kind = kind;
2193
- var b = document.createElement('b');
2194
- b.textContent = tag;
2195
- row.appendChild(b);
2196
- var span = document.createElement('span');
2197
- span.textContent = text;
2198
- row.appendChild(span);
2199
- el('steps').appendChild(row);
2200
- Bridge.reportSize();
2201
- }
2202
-
2203
- // ── catalogue ───────────────────────────────────────────────────────────
2204
- function matches(app, q) {
2205
- if (!q) { return true; }
2206
- var hay = [app.name, app.display_name, app.category]
2207
- .concat(Array.isArray(app.tags) ? app.tags : [])
2208
- .join(' ')
2209
- .toLowerCase();
2210
- return hay.indexOf(q) !== -1;
2211
- }
2212
-
2213
- function renderTiles() {
2214
- var q = el('search').value.trim().toLowerCase();
2215
- var box = el('tiles');
2216
- box.textContent = '';
2217
- var shown = apps.filter(function (a) { return matches(a, q); });
2218
- shown.forEach(function (app) {
2219
- var b = document.createElement('button');
2220
- b.type = 'button';
2221
- b.className = 'tile';
2222
- b.setAttribute('role', 'option');
2223
- b.setAttribute('aria-pressed', picked && picked.name === app.name ? 'true' : 'false');
2224
-
2225
- var m = document.createElement('span');
2226
- m.className = 'mono';
2227
- m.textContent = monogram(app.name);
2228
- b.appendChild(m);
2229
-
2230
- var wrap = document.createElement('span');
2231
- var n = document.createElement('span');
2232
- n.className = 'tile-name';
2233
- n.textContent = String(app.display_name || app.name || '?');
2234
- wrap.appendChild(n);
2235
- var v = document.createElement('span');
2236
- v.className = 'tile-ver';
2237
- v.textContent = String(app.version || '');
2238
- wrap.appendChild(document.createElement('br'));
2239
- wrap.appendChild(v);
2240
- b.appendChild(wrap);
2241
-
2242
- b.addEventListener('click', function () { select(app); });
2243
- box.appendChild(b);
2244
- });
2245
- el('none').hidden = shown.length > 0;
2246
- Bridge.reportSize();
2247
- }
2248
-
2249
- // ── configure ───────────────────────────────────────────────────────────
2250
- function select(app) {
2251
- picked = app;
2252
- el('pick').hidden = true;
2253
- el('form').hidden = false;
2254
- el('title').textContent = 'Deploy ' + String(app.display_name || app.name);
2255
- el('sub').textContent = String(app.category || '') + (app.version ? (' · ' + app.version) : '');
2256
- setChip('idle', 'Not started');
2257
-
2258
- var r = app.requirements || {};
2259
- var bits = [];
2260
- if (r.cpu_cores) { bits.push(r.cpu_cores + ' vCPU'); }
2261
- if (r.ram_mb) { bits.push(r.ram_mb + ' MB RAM'); }
2262
- if (r.disk_gb) { bits.push(r.disk_gb + ' GB disk'); }
2263
- if (Array.isArray(r.ports) && r.ports.length) { bits.push('port ' + r.ports.join(', ')); }
2264
- el('needs').textContent = bits.length ? ('Needs ' + bits.join(' · ')) : '';
2265
-
2266
- // Only what this app says it supports. Offering a field the deploy then
2267
- // rejects is worse than not offering it.
2268
- var sup = app.supports || {};
2269
- el('domainRow').hidden = sup.custom_domain === false;
2270
- el('onionRow').hidden = !sup.onion;
2271
- if (!sup.onion) { el('onion').checked = false; }
2272
-
2273
- say('');
2274
- Bridge.reportSize();
2275
- }
2276
-
2277
- function renderServers() {
2278
- var sel = el('agent');
2279
- sel.textContent = '';
2280
- var online = servers.filter(function (s) { return s.agent_id; });
2281
- if (online.length === 0) {
2282
- var opt = document.createElement('option');
2283
- opt.value = '';
2284
- opt.textContent = 'No agent-enabled server on this account';
2285
- sel.appendChild(opt);
2286
- sel.disabled = true;
2287
- el('deploy').disabled = true;
2288
- return;
2289
- }
2290
- online.forEach(function (s) {
2291
- var opt = document.createElement('option');
2292
- opt.value = String(s.agent_id);
2293
- var label = String(s.hostname || s.agent_id);
2294
- if (s.status && String(s.status).toLowerCase() !== 'online') {
2295
- label += ' (' + String(s.status) + ')';
2296
- }
2297
- opt.textContent = label;
2298
- sel.appendChild(opt);
2299
- });
2300
- sel.disabled = false;
2301
- el('deploy').disabled = false;
2302
- }
2303
-
2304
- // ── deploy + watch ──────────────────────────────────────────────────────
2305
- function startDeploy() {
2306
- if (busy || !picked) { return; }
2307
- var agentId = el('agent').value;
2308
- if (!agentId) { say('Pick a server with the Impreza agent installed.', true); return; }
2309
-
2310
- var args = { app_name: picked.name, agent_id: agentId };
2311
- var domain = el('domain').value.trim();
2312
- if (domain) { args.domain = domain; }
2313
- if (el('onion').checked) { args.onion = true; }
2314
-
2315
- busy = true;
2316
- el('deploy').disabled = true;
2317
- el('form').hidden = true;
2318
- el('progress').hidden = false;
2319
- el('steps').textContent = '';
2320
- setChip('pending', 'Deploying');
2321
- step('work', 'sent', 'Asked the server to deploy ' + String(picked.display_name || picked.name) + '.');
2322
-
2323
- Bridge.callTool('impreza_deploy_catalog_app', args)
2324
- .then(function (d) {
2325
- // A host that speaks the Tasks extension hands back a task handle
2326
- // instead of the deployment; either way the deployment's own status is
2327
- // what we watch, so both shapes lead to the same place.
2328
- deploymentId = (d && (d.id || d.deployment_id)) || null;
2329
- if (d && d.taskId && !deploymentId) {
2330
- step('work', 'task', 'The host is tracking this as a task; watching the deployment directly.');
2331
- }
2332
- if (d && d.domain) {
2333
- showUrl(d.domain);
2334
- }
2335
- step('work', 'queued', 'Accepted. Pulling images and starting the app — usually a few minutes.');
2336
- watch();
2337
- })
2338
- .catch(function (e) {
2339
- busy = false;
2340
- setChip('error', 'Failed');
2341
- step('bad', 'error', e.message || 'The deploy was refused.');
2342
- el('again').hidden = false;
2343
- });
2344
- }
2345
-
2346
- function showUrl(domain) {
2347
- var u = String(domain || '');
2348
- if (!u) { return; }
2349
- if (u.indexOf('http') !== 0) { u = 'https://' + u; }
2350
- el('url').textContent = u;
2351
- el('result').hidden = false;
2352
- el('open').hidden = !Bridge.canOpenLinks();
2353
- Bridge.reportSize();
2354
- }
2355
-
2356
- var seenStatus = '';
2357
- function watch() {
2358
- clearTimeout(watchTimer);
2359
- watchTicks++;
2360
- // ~5 minutes at 6s. A deploy that has not settled by then has a real
2361
- // problem, and saying so beats polling forever.
2362
- if (watchTicks > 50) {
2363
- setChip('pending', 'Still working');
2364
- step('work', 'slow', 'Still not settled. Check impreza_list_deployments in a moment.');
2365
- busy = false;
2366
- el('again').hidden = false;
2367
- return;
2368
- }
2369
-
2370
- var agentId = el('agent').value;
2371
- Bridge.callTool('impreza_list_deployments', agentId ? { agent_id: agentId } : {})
2372
- .then(function (d) {
2373
- var rows = (d && d.deployments) || [];
2374
- var row = null;
2375
- if (deploymentId) {
2376
- row = rows.filter(function (r) { return r.id === deploymentId; })[0] || null;
2377
- }
2378
- if (!row) {
2379
- // No id to match on: the newest row for this app is the one we just
2380
- // asked for.
2381
- row = rows
2382
- .filter(function (r) { return r.app_name === picked.name; })
2383
- .sort(function (a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); })[0] || null;
2384
- }
2385
- if (!row) {
2386
- watchTimer = setTimeout(watch, 6000);
2387
- return;
2388
- }
2389
- deploymentId = row.id || deploymentId;
2390
- var st = String(row.status || '').toLowerCase();
2391
- if (st && st !== seenStatus) {
2392
- seenStatus = st;
2393
- if (st !== 'running' && st !== 'failed') { step('work', st, 'Status is now ' + st + '.'); }
2394
- }
2395
- if (row.domain) { showUrl(row.domain); }
2396
-
2397
- if (st === 'running') {
2398
- setChip('ok', 'Running');
2399
- step('ok', 'done', 'Deployed and healthy.');
2400
- busy = false;
2401
- el('again').hidden = false;
2402
- return;
2403
- }
2404
- if (st === 'failed') {
2405
- setChip('error', 'Failed');
2406
- step('bad', 'failed', String(row.last_error || 'The deploy failed.').slice(0, 240));
2407
- busy = false;
2408
- el('again').hidden = false;
2409
- return;
2410
- }
2411
- watchTimer = setTimeout(watch, 6000);
2412
- })
2413
- .catch(function () { watchTimer = setTimeout(watch, 8000); });
2414
- }
2415
-
2416
- function reset() {
2417
- clearTimeout(watchTimer);
2418
- watchTicks = 0;
2419
- seenStatus = '';
2420
- deploymentId = null;
2421
- busy = false;
2422
- picked = null;
2423
- el('progress').hidden = true;
2424
- el('result').hidden = true;
2425
- el('open').hidden = true;
2426
- el('again').hidden = true;
2427
- el('form').hidden = true;
2428
- el('pick').hidden = false;
2429
- el('deploy').disabled = false;
2430
- el('chip').hidden = true;
2431
- el('title').textContent = 'Deploy an app';
2432
- el('sub').textContent = apps.length + ' apps in the catalogue';
2433
- renderTiles();
2434
- }
2435
-
2436
- // ── wiring ──────────────────────────────────────────────────────────────
2437
- el('search').addEventListener('input', renderTiles);
2438
- el('deploy').addEventListener('click', startDeploy);
2439
- el('back').addEventListener('click', reset);
2440
- el('again').addEventListener('click', reset);
2441
- el('copyUrl').addEventListener('click', function () { Bridge.copy(el('url').textContent, this); });
2442
- el('open').addEventListener('click', function () {
2443
- Bridge.openLink(el('url').textContent)
2444
- .catch(function () { say('The host declined to open the link.', true); });
2445
- });
2446
-
2447
- Bridge.start({
2448
- name: 'impreza-deploy-wizard',
2449
- onResult: function (d) {
2450
- apps = (d && d.apps) || [];
2451
- el('pick').hidden = false;
2452
- el('sub').textContent = apps.length + ' apps in the catalogue';
2453
- renderTiles();
2454
- // One enrichment call: the deploy needs a target, and only the server
2455
- // list maps this account's agents.
2456
- Bridge.callTool('impreza_list_servers', {})
2457
- .then(function (r) { servers = (r && r.servers) || []; renderServers(); })
2458
- .catch(function () {
2459
- servers = [];
2460
- renderServers();
2461
- say('Could not list your servers — reload the panel to try again.', true);
2462
- });
2463
- },
2464
- onFail: function (text) {
2465
- el('sub').textContent = '';
2466
- setChip('error', 'Unavailable');
2467
- say(String(text || 'Could not load the catalogue.'), true);
2468
- }
2469
- });
2470
- })();
2471
- </script>
2472
- </body>
2473
- </html>
1577
+ export const DEPLOY_WIZARD_HTML = `<!DOCTYPE html>
1578
+ <html lang="en">
1579
+ <head>
1580
+ <meta charset="utf-8">
1581
+
1582
+ <title>Deploy an app</title>
1583
+ <!--
1584
+ Impreza Host — MCP Apps view for impreza_list_apps.
1585
+
1586
+ Browsing a catalogue, filling three fields and watching a deploy is the
1587
+ clearest case in this product for a form over a conversation: the questions
1588
+ are fixed, the answers are short, and the progress is a state machine.
1589
+
1590
+ Three decisions worth knowing:
1591
+
1592
+ · The form offers ONLY what the selected app declares it supports. The
1593
+ catalogue carries \`supports.custom_domain\` and \`supports.onion\` per app, so
1594
+ a domain field on an app that cannot take one, or an onion toggle on an app
1595
+ with no hidden-service support, would be a promise the deploy then breaks.
1596
+ The requirements line is drawn from the app's own numbers for the same
1597
+ reason.
1598
+
1599
+ · No app icons. The catalogue gives \`icon_url\`, and fetching it would be a
1600
+ network request from a panel whose declared CSP has none — so the tiles use
1601
+ a monogram from the app's own name. The constraint shaped the design rather
1602
+ than being worked around.
1603
+
1604
+ · Progress is read from the deployment's own status column via
1605
+ impreza_list_deployments, not from a second progress model. That works
1606
+ whether or not the host speaks the Tasks extension: a host that does gets a
1607
+ task handle back from the deploy, one that does not gets the deployment
1608
+ JSON, and polling the list answers both.
1609
+
1610
+ This panel deliberately calls no confirmation-gated tool. Those exist because
1611
+ intent must be proven across two SEPARATE calls, and a view doing both halves
1612
+ on one click would defeat the gate rather than satisfy it.
1613
+ -->
1614
+ <style>
1615
+ /* Impreza Host — shared tokens and primitives for every MCP Apps panel.
1616
+ *
1617
+ * Substituted into each panel's TOKENS marker by McpUi::read() and by
1618
+ * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch
1619
+ * nothing from the network, so the choice was one substitution or three copies.
1620
+ * Keeping it in one file is also what makes our panels read as one family
1621
+ * rather than three pages that happen to ship together.
1622
+ *
1623
+ * Host-provided variables win. These fallbacks are what a panel looks like when
1624
+ * a host sends none — which the spec warns is common, along with partial sets.
1625
+ * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.
1626
+ */
1627
+
1628
+ :root {
1629
+ color-scheme: light dark;
1630
+
1631
+ --color-background-primary: light-dark(#fcfcfb, #1b1b1a);
1632
+ --color-background-secondary: light-dark(#f4f3ef, #232320);
1633
+ --color-background-tertiary: light-dark(#eceae4, #2b2a26);
1634
+ --color-text-primary: light-dark(#1a1a17, #f2f1ed);
1635
+ --color-text-secondary: light-dark(#6e6d66, #a5a49c);
1636
+ --color-text-tertiary: light-dark(#8d8c84, #7e7d76);
1637
+ --color-text-warning: light-dark(#8a5a00, #e0a83a);
1638
+ --color-text-success: light-dark(#1f6b3a, #5fbf83);
1639
+ --color-text-danger: light-dark(#9a2b21, #f08a7e);
1640
+ --color-border-primary: light-dark(#e5e4de, #33302b);
1641
+ --color-border-secondary: light-dark(#efeee9, #2a2825);
1642
+
1643
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
1644
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1645
+
1646
+ --border-radius-sm: 6px;
1647
+ --border-radius-md: 10px;
1648
+ --border-radius-full: 999px;
1649
+ }
1650
+
1651
+ * { box-sizing: border-box; }
1652
+
1653
+ body {
1654
+ margin: 0;
1655
+ background: var(--color-background-primary);
1656
+ color: var(--color-text-primary);
1657
+ font-family: var(--font-sans);
1658
+ font-size: 14px;
1659
+ line-height: 1.45;
1660
+ -webkit-font-smoothing: antialiased;
1661
+ }
1662
+
1663
+ .card {
1664
+ display: flex;
1665
+ flex-direction: column;
1666
+ gap: 14px;
1667
+ padding: 16px;
1668
+ max-width: 560px;
1669
+ }
1670
+
1671
+ /* ── header ───────────────────────────────────────────────────────────── */
1672
+ .head {
1673
+ display: flex;
1674
+ align-items: flex-start;
1675
+ justify-content: space-between;
1676
+ gap: 12px;
1677
+ }
1678
+ .title {
1679
+ font-family: var(--font-mono);
1680
+ font-size: 18px;
1681
+ letter-spacing: -0.01em;
1682
+ overflow-wrap: anywhere;
1683
+ }
1684
+ .total {
1685
+ font-family: var(--font-mono);
1686
+ font-size: 22px;
1687
+ font-variant-numeric: tabular-nums;
1688
+ letter-spacing: -0.01em;
1689
+ }
1690
+ .sub {
1691
+ margin-top: 2px;
1692
+ color: var(--color-text-secondary);
1693
+ font-size: 12px;
1694
+ }
1695
+
1696
+ /* State, and state only — the one place colour is allowed to catch the eye. */
1697
+ .chip {
1698
+ flex: none;
1699
+ padding: 3px 10px;
1700
+ border: 1px solid var(--color-border-primary);
1701
+ border-radius: var(--border-radius-full);
1702
+ font-size: 11px;
1703
+ font-weight: 600;
1704
+ letter-spacing: 0.04em;
1705
+ text-transform: uppercase;
1706
+ white-space: nowrap;
1707
+ }
1708
+ .chip[data-state="pending"] { color: var(--color-text-warning); }
1709
+ .chip[data-state="paid"],
1710
+ .chip[data-state="ok"] { color: var(--color-text-success); }
1711
+ .chip[data-state="error"],
1712
+ .chip[data-state="off"] { color: var(--color-text-danger); }
1713
+ .chip[data-state="idle"] { color: var(--color-text-secondary); }
1714
+
1715
+ /* ── labelled value blocks ────────────────────────────────────────────── */
1716
+ .field {
1717
+ border: 1px solid var(--color-border-primary);
1718
+ border-radius: var(--border-radius-md);
1719
+ background: var(--color-background-secondary);
1720
+ padding: 10px 12px;
1721
+ }
1722
+ .field-head {
1723
+ display: flex;
1724
+ align-items: baseline;
1725
+ justify-content: space-between;
1726
+ gap: 8px;
1727
+ margin-bottom: 6px;
1728
+ }
1729
+ .label {
1730
+ color: var(--color-text-tertiary);
1731
+ font-size: 10px;
1732
+ font-weight: 700;
1733
+ letter-spacing: 0.08em;
1734
+ text-transform: uppercase;
1735
+ }
1736
+ .value {
1737
+ font-family: var(--font-mono);
1738
+ font-size: 15px;
1739
+ font-variant-numeric: tabular-nums;
1740
+ overflow-wrap: anywhere;
1741
+ user-select: all;
1742
+ }
1743
+
1744
+ /* ── controls ─────────────────────────────────────────────────────────── */
1745
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1746
+ .btn {
1747
+ padding: 6px 13px;
1748
+ border: 1px solid var(--color-border-primary);
1749
+ border-radius: var(--border-radius-sm);
1750
+ background: var(--color-background-tertiary);
1751
+ color: var(--color-text-primary);
1752
+ font: inherit;
1753
+ font-size: 12px;
1754
+ font-weight: 600;
1755
+ cursor: pointer;
1756
+ }
1757
+ .btn:disabled { opacity: 0.5; cursor: default; }
1758
+ .btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1759
+ /* Armed: one more click fires it. Used where a mis-click costs an afternoon. */
1760
+ .btn[data-armed="1"] {
1761
+ color: var(--color-text-danger);
1762
+ border-color: var(--color-text-danger);
1763
+ }
1764
+ .btn.quiet {
1765
+ background: transparent;
1766
+ color: var(--color-text-secondary);
1767
+ }
1768
+ .btn.quiet:hover { color: var(--color-text-primary); }
1769
+ .copy {
1770
+ flex: none;
1771
+ padding: 3px 9px;
1772
+ border: 1px solid var(--color-border-primary);
1773
+ border-radius: var(--border-radius-sm);
1774
+ background: var(--color-background-primary);
1775
+ color: var(--color-text-secondary);
1776
+ font: inherit;
1777
+ font-size: 11px;
1778
+ font-weight: 600;
1779
+ cursor: pointer;
1780
+ }
1781
+ .copy:hover { color: var(--color-text-primary); }
1782
+ .copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1783
+
1784
+ input[type="text"], select {
1785
+ width: 100%;
1786
+ padding: 6px 9px;
1787
+ border: 1px solid var(--color-border-primary);
1788
+ border-radius: var(--border-radius-sm);
1789
+ background: var(--color-background-primary);
1790
+ color: var(--color-text-primary);
1791
+ font: inherit;
1792
+ font-size: 13px;
1793
+ }
1794
+ input[type="text"] { font-family: var(--font-mono); }
1795
+ input:focus-visible, select:focus-visible {
1796
+ outline: 2px solid var(--color-text-secondary);
1797
+ outline-offset: 1px;
1798
+ }
1799
+ .check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
1800
+ .check input { margin: 0; }
1801
+
1802
+ /* ── notes ────────────────────────────────────────────────────────────── */
1803
+ .note { color: var(--color-text-secondary); font-size: 11.5px; }
1804
+ .note strong { color: var(--color-text-primary); font-weight: 600; }
1805
+ .warn { color: var(--color-text-danger); }
1806
+ .rest { color: var(--color-text-secondary); font-size: 13px; }
1807
+
1808
+ [hidden] { display: none !important; }
1809
+
1810
+ @media (prefers-reduced-motion: no-preference) {
1811
+ .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }
1812
+ }
1813
+
1814
+
1815
+ /* ── search + catalogue ───────────────────────────────────────────────── */
1816
+ .tiles {
1817
+ display: grid;
1818
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1819
+ gap: 6px;
1820
+ max-height: 260px;
1821
+ overflow-y: auto;
1822
+ }
1823
+ .tile {
1824
+ display: flex;
1825
+ align-items: center;
1826
+ gap: 9px;
1827
+ padding: 8px 9px;
1828
+ border: 1px solid var(--color-border-secondary);
1829
+ border-radius: var(--border-radius-sm);
1830
+ background: transparent;
1831
+ color: inherit;
1832
+ font: inherit;
1833
+ text-align: left;
1834
+ cursor: pointer;
1835
+ }
1836
+ .tile:hover { border-color: var(--color-border-primary); }
1837
+ .tile[aria-pressed="true"] {
1838
+ border-color: var(--color-text-secondary);
1839
+ background: var(--color-background-secondary);
1840
+ }
1841
+ .tile:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
1842
+ /* No icon_url fetch — a monogram from the app's own name instead. */
1843
+ .mono {
1844
+ flex: none;
1845
+ width: 26px;
1846
+ height: 26px;
1847
+ display: grid;
1848
+ place-items: center;
1849
+ border-radius: var(--border-radius-sm);
1850
+ background: var(--color-background-tertiary);
1851
+ font-family: var(--font-mono);
1852
+ font-size: 12px;
1853
+ font-weight: 700;
1854
+ }
1855
+ .tile-name { font-size: 12.5px; font-weight: 600; line-height: 1.25; }
1856
+ .tile-ver {
1857
+ color: var(--color-text-tertiary);
1858
+ font-family: var(--font-mono);
1859
+ font-size: 10.5px;
1860
+ }
1861
+
1862
+ /* ── the form ─────────────────────────────────────────────────────────── */
1863
+ .form { display: flex; flex-direction: column; gap: 10px; }
1864
+ .row { display: flex; flex-direction: column; gap: 4px; }
1865
+ .needs {
1866
+ color: var(--color-text-secondary);
1867
+ font-family: var(--font-mono);
1868
+ font-size: 11.5px;
1869
+ }
1870
+
1871
+ /* ── progress ─────────────────────────────────────────────────────────── */
1872
+ .steps { display: flex; flex-direction: column; gap: 5px; }
1873
+ .step {
1874
+ display: flex;
1875
+ align-items: baseline;
1876
+ gap: 8px;
1877
+ font-size: 12.5px;
1878
+ color: var(--color-text-secondary);
1879
+ }
1880
+ .step b {
1881
+ font-family: var(--font-mono);
1882
+ font-size: 10px;
1883
+ font-weight: 700;
1884
+ letter-spacing: 0.06em;
1885
+ text-transform: uppercase;
1886
+ min-width: 4.5em;
1887
+ }
1888
+ .step[data-kind="ok"] b { color: var(--color-text-success); }
1889
+ .step[data-kind="work"] b { color: var(--color-text-warning); }
1890
+ .step[data-kind="bad"] b { color: var(--color-text-danger); }
1891
+ </style>
1892
+ </head>
1893
+ <body>
1894
+ <div class="card">
1895
+ <div class="head">
1896
+ <div>
1897
+ <div class="title" id="title">Deploy an app</div>
1898
+ <div class="sub" id="sub">Loading the catalogue…</div>
1899
+ </div>
1900
+ <div class="chip" id="chip" data-state="idle" hidden>Idle</div>
1901
+ </div>
1902
+
1903
+ <!-- ── pick ────────────────────────────────────────────────────────── -->
1904
+ <div id="pick" hidden>
1905
+ <div class="row" style="margin-bottom:8px">
1906
+ <label class="label" for="search">Search the catalogue</label>
1907
+ <input type="text" id="search" autocomplete="off" spellcheck="false" placeholder="name, category or tag">
1908
+ </div>
1909
+ <div class="tiles" id="tiles" role="listbox" aria-label="Catalogue apps"></div>
1910
+ <div class="note" id="none" hidden style="margin-top:8px">Nothing in the catalogue matches that.</div>
1911
+ </div>
1912
+
1913
+ <!-- ── configure ───────────────────────────────────────────────────── -->
1914
+ <div class="form" id="form" hidden>
1915
+ <div class="needs" id="needs"></div>
1916
+ <div class="row">
1917
+ <label class="label" for="agent">Deploy to</label>
1918
+ <select id="agent"></select>
1919
+ </div>
1920
+ <div class="row" id="domainRow">
1921
+ <label class="label" for="domain">Domain <span style="text-transform:none;letter-spacing:0">— leave blank for a free *.imprezaapps.com hostname</span></label>
1922
+ <input type="text" id="domain" autocomplete="off" spellcheck="false" placeholder="app.example.com">
1923
+ </div>
1924
+ <label class="check" id="onionRow">
1925
+ <input type="checkbox" id="onion">
1926
+ <span>Also publish a Tor hidden service (.onion) mirror</span>
1927
+ </label>
1928
+ <div class="actions">
1929
+ <button class="btn" id="deploy" type="button">Deploy</button>
1930
+ <button class="btn quiet" id="back" type="button">Pick another app</button>
1931
+ <span class="note" id="status"></span>
1932
+ </div>
1933
+ </div>
1934
+
1935
+ <!-- ── watch ───────────────────────────────────────────────────────── -->
1936
+ <div id="progress" hidden>
1937
+ <div class="steps" id="steps"></div>
1938
+ <div class="field" id="result" hidden style="margin-top:10px">
1939
+ <div class="field-head">
1940
+ <span class="label">Live at</span>
1941
+ <button class="copy" id="copyUrl" type="button">Copy</button>
1942
+ </div>
1943
+ <div class="value" id="url"></div>
1944
+ </div>
1945
+ <div class="actions" style="margin-top:10px">
1946
+ <button class="btn" id="open" type="button" hidden>Open</button>
1947
+ <button class="btn quiet" id="again" type="button" hidden>Deploy another</button>
1948
+ </div>
1949
+ </div>
1950
+ </div>
1951
+
1952
+ <script>
1953
+ // Impreza Host — the MCP Apps host bridge, shared by every panel.
1954
+ //
1955
+ // Substituted into each panel's BRIDGE marker by McpUi::read() and by
1956
+ // tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch
1957
+ // nothing from the network, so the only alternatives to substitution were three
1958
+ // copies of this file or a build step. One mechanism, one place the verifier has
1959
+ // to lint, and the panels stay readable.
1960
+ //
1961
+ // Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:
1962
+ // · No network. Every value arrives over this bridge.
1963
+ // · Never innerHTML. Tool values reach the DOM through textContent only.
1964
+ // · Never inject hostContext.styles.css.fonts — the spec lets a host hand us
1965
+ // an @import from a font service and its own applyHostFonts() injects it
1966
+ // verbatim, which on our panels is a third-party request identifying the
1967
+ // viewer. Colour and radius VARIABLES yes; the host's font CSS never.
1968
+
1969
+ 'use strict';
1970
+
1971
+ var Bridge = (function () {
1972
+ var nextId = 1;
1973
+ var pending = Object.create(null);
1974
+ var handlers = Object.create(null);
1975
+ var hostCaps = {};
1976
+
1977
+ window.addEventListener('message', function (event) {
1978
+ var msg = event.data;
1979
+ if (!msg || msg.jsonrpc !== '2.0') { return; }
1980
+ if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
1981
+ var p = pending[msg.id];
1982
+ delete pending[msg.id];
1983
+ if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }
1984
+ else { p.resolve(msg.result); }
1985
+ return;
1986
+ }
1987
+ if (msg.method && handlers[msg.method]) {
1988
+ // A malformed notification must not take the panel down with it.
1989
+ try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }
1990
+ }
1991
+ });
1992
+
1993
+ function request(method, params) {
1994
+ var id = nextId++;
1995
+ return new Promise(function (resolve, reject) {
1996
+ pending[id] = { resolve: resolve, reject: reject };
1997
+ window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
1998
+ });
1999
+ }
2000
+
2001
+ function notify(method, params) {
2002
+ window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
2003
+ }
2004
+
2005
+ function on(method, fn) { handlers[method] = fn; }
2006
+
2007
+ /**
2008
+ * Call one of OUR tools through the host. The host decides whether to
2009
+ * forward it and may ask the viewer first — a panel never assumes consent.
2010
+ * Resolves with the tool's structuredContent when it has one, falling back to
2011
+ * parsing the text block for a host that only forwards \`content\`.
2012
+ */
2013
+ function callTool(name, args) {
2014
+ return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {
2015
+ res = res || {};
2016
+ if (res.isError) {
2017
+ var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';
2018
+ throw new Error(text);
2019
+ }
2020
+ return unwrap(res);
2021
+ });
2022
+ }
2023
+
2024
+ /** structuredContent if present, else the text block parsed as JSON. */
2025
+ function unwrap(payload) {
2026
+ if (!payload) { return null; }
2027
+ if (payload.structuredContent) { return payload.structuredContent; }
2028
+ var t = payload.content && payload.content[0] && payload.content[0].text;
2029
+ if (typeof t === 'string') {
2030
+ try { return JSON.parse(t); } catch (e) { return null; }
2031
+ }
2032
+ return null;
2033
+ }
2034
+
2035
+ function applyStyles(styles) {
2036
+ if (!styles || !styles.variables) { return; }
2037
+ Object.keys(styles.variables).forEach(function (k) {
2038
+ var v = styles.variables[k];
2039
+ // Only custom properties, and only plain values: a variable is otherwise
2040
+ // a place a host could smuggle CSS into our document.
2041
+ if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {
2042
+ document.documentElement.style.setProperty(k, v);
2043
+ }
2044
+ });
2045
+ // styles.css.fonts is DELIBERATELY ignored. See the note at the top.
2046
+ }
2047
+
2048
+ function applyTheme(theme) {
2049
+ if (theme === 'light' || theme === 'dark') {
2050
+ document.documentElement.style.colorScheme = theme;
2051
+ }
2052
+ }
2053
+
2054
+ var lastH = 0;
2055
+ function reportSize() {
2056
+ var h = Math.ceil(document.documentElement.scrollHeight);
2057
+ var w = Math.ceil(document.documentElement.scrollWidth);
2058
+ if (h === lastH) { return; }
2059
+ lastH = h;
2060
+ notify('ui/notifications/size-changed', { width: w, height: h });
2061
+ }
2062
+
2063
+ /**
2064
+ * Handshake, then hand the panel its data.
2065
+ *
2066
+ * onResult receives the tool result (unwrapped), onInput the tool arguments,
2067
+ * onFail a message when there is no bridge or the call was cancelled.
2068
+ */
2069
+ function start(opts) {
2070
+ opts = opts || {};
2071
+ var onResult = opts.onResult || function () {};
2072
+ var onInput = opts.onInput || function () {};
2073
+ var onFail = opts.onFail || function () {};
2074
+
2075
+ on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });
2076
+ on('ui/notifications/tool-result', function (params) {
2077
+ if (params && params.isError) {
2078
+ var text = (params.content && params.content[0] && params.content[0].text) || '';
2079
+ onFail(text || 'The tool call failed.');
2080
+ return;
2081
+ }
2082
+ var data = unwrap(params);
2083
+ if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }
2084
+ reportSize();
2085
+ });
2086
+ on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });
2087
+ on('ui/notifications/host-context-changed', function (params) {
2088
+ if (!params) { return; }
2089
+ if (params.theme) { applyTheme(params.theme); }
2090
+ if (params.styles) { applyStyles(params.styles); }
2091
+ });
2092
+
2093
+ if (window.ResizeObserver) {
2094
+ new ResizeObserver(reportSize).observe(document.body);
2095
+ }
2096
+
2097
+ return request('ui/initialize', {
2098
+ protocolVersion: '2026-01-26',
2099
+ clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
2100
+ appCapabilities: {}
2101
+ }).then(function (result) {
2102
+ result = result || {};
2103
+ hostCaps = result.capabilities || {};
2104
+ var ctx = result.hostContext || {};
2105
+ applyTheme(ctx.theme);
2106
+ applyStyles(ctx.styles);
2107
+ notify('ui/notifications/initialized', {});
2108
+ reportSize();
2109
+ return { capabilities: hostCaps, hostContext: ctx };
2110
+ }, function () {
2111
+ // Standalone, with no host on the other end. Saying so beats a spinner
2112
+ // that never resolves.
2113
+ onFail('This panel needs an MCP host to load its data.');
2114
+ throw new Error('no host bridge');
2115
+ });
2116
+ }
2117
+
2118
+ /** Ask the host to open a URL. Only offered when it says it can. */
2119
+ function openLink(url) {
2120
+ return request('ui/open-link', { url: String(url) });
2121
+ }
2122
+
2123
+ function canOpenLinks() { return !!hostCaps.openLinks; }
2124
+
2125
+ /** Copy text, with a fallback because permissions are never guaranteed. */
2126
+ function copy(text, button, okLabel, failLabel) {
2127
+ var done = function (ok) {
2128
+ var original = button.dataset.label || button.textContent;
2129
+ button.dataset.label = original;
2130
+ button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');
2131
+ setTimeout(function () { button.textContent = button.dataset.label; }, 1400);
2132
+ };
2133
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2134
+ navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
2135
+ return;
2136
+ }
2137
+ done(false);
2138
+ }
2139
+
2140
+ return {
2141
+ request: request,
2142
+ notify: notify,
2143
+ on: on,
2144
+ callTool: callTool,
2145
+ unwrap: unwrap,
2146
+ start: start,
2147
+ reportSize: reportSize,
2148
+ openLink: openLink,
2149
+ canOpenLinks: canOpenLinks,
2150
+ copy: copy
2151
+ };
2152
+ })();
2153
+
2154
+ /** Shorthand every panel uses. */
2155
+ function el(id) { return document.getElementById(id); }
2156
+
2157
+
2158
+ (function () {
2159
+ 'use strict';
2160
+
2161
+ var apps = [];
2162
+ var servers = [];
2163
+ var picked = null;
2164
+ var busy = false;
2165
+ var watchTimer = null;
2166
+ var watchTicks = 0;
2167
+ var deploymentId = null;
2168
+
2169
+ // ── helpers ─────────────────────────────────────────────────────────────
2170
+ function monogram(name) {
2171
+ var s = String(name || '?').replace(/[^A-Za-z0-9]/g, '');
2172
+ return (s.slice(0, 2) || '?').toUpperCase();
2173
+ }
2174
+
2175
+ function say(text, bad) {
2176
+ var s = el('status');
2177
+ s.className = bad ? 'note warn' : 'note';
2178
+ s.textContent = text || '';
2179
+ Bridge.reportSize();
2180
+ }
2181
+
2182
+ function setChip(state, text) {
2183
+ var chip = el('chip');
2184
+ chip.hidden = false;
2185
+ chip.dataset.state = state;
2186
+ chip.textContent = text;
2187
+ }
2188
+
2189
+ function step(kind, tag, text) {
2190
+ var row = document.createElement('div');
2191
+ row.className = 'step';
2192
+ row.dataset.kind = kind;
2193
+ var b = document.createElement('b');
2194
+ b.textContent = tag;
2195
+ row.appendChild(b);
2196
+ var span = document.createElement('span');
2197
+ span.textContent = text;
2198
+ row.appendChild(span);
2199
+ el('steps').appendChild(row);
2200
+ Bridge.reportSize();
2201
+ }
2202
+
2203
+ // ── catalogue ───────────────────────────────────────────────────────────
2204
+ function matches(app, q) {
2205
+ if (!q) { return true; }
2206
+ var hay = [app.name, app.display_name, app.category]
2207
+ .concat(Array.isArray(app.tags) ? app.tags : [])
2208
+ .join(' ')
2209
+ .toLowerCase();
2210
+ return hay.indexOf(q) !== -1;
2211
+ }
2212
+
2213
+ function renderTiles() {
2214
+ var q = el('search').value.trim().toLowerCase();
2215
+ var box = el('tiles');
2216
+ box.textContent = '';
2217
+ var shown = apps.filter(function (a) { return matches(a, q); });
2218
+ shown.forEach(function (app) {
2219
+ var b = document.createElement('button');
2220
+ b.type = 'button';
2221
+ b.className = 'tile';
2222
+ b.setAttribute('role', 'option');
2223
+ b.setAttribute('aria-pressed', picked && picked.name === app.name ? 'true' : 'false');
2224
+
2225
+ var m = document.createElement('span');
2226
+ m.className = 'mono';
2227
+ m.textContent = monogram(app.name);
2228
+ b.appendChild(m);
2229
+
2230
+ var wrap = document.createElement('span');
2231
+ var n = document.createElement('span');
2232
+ n.className = 'tile-name';
2233
+ n.textContent = String(app.display_name || app.name || '?');
2234
+ wrap.appendChild(n);
2235
+ var v = document.createElement('span');
2236
+ v.className = 'tile-ver';
2237
+ v.textContent = String(app.version || '');
2238
+ wrap.appendChild(document.createElement('br'));
2239
+ wrap.appendChild(v);
2240
+ b.appendChild(wrap);
2241
+
2242
+ b.addEventListener('click', function () { select(app); });
2243
+ box.appendChild(b);
2244
+ });
2245
+ el('none').hidden = shown.length > 0;
2246
+ Bridge.reportSize();
2247
+ }
2248
+
2249
+ // ── configure ───────────────────────────────────────────────────────────
2250
+ function select(app) {
2251
+ picked = app;
2252
+ el('pick').hidden = true;
2253
+ el('form').hidden = false;
2254
+ el('title').textContent = 'Deploy ' + String(app.display_name || app.name);
2255
+ el('sub').textContent = String(app.category || '') + (app.version ? (' · ' + app.version) : '');
2256
+ setChip('idle', 'Not started');
2257
+
2258
+ var r = app.requirements || {};
2259
+ var bits = [];
2260
+ if (r.cpu_cores) { bits.push(r.cpu_cores + ' vCPU'); }
2261
+ if (r.ram_mb) { bits.push(r.ram_mb + ' MB RAM'); }
2262
+ if (r.disk_gb) { bits.push(r.disk_gb + ' GB disk'); }
2263
+ if (Array.isArray(r.ports) && r.ports.length) { bits.push('port ' + r.ports.join(', ')); }
2264
+ el('needs').textContent = bits.length ? ('Needs ' + bits.join(' · ')) : '';
2265
+
2266
+ // Only what this app says it supports. Offering a field the deploy then
2267
+ // rejects is worse than not offering it.
2268
+ var sup = app.supports || {};
2269
+ el('domainRow').hidden = sup.custom_domain === false;
2270
+ el('onionRow').hidden = !sup.onion;
2271
+ if (!sup.onion) { el('onion').checked = false; }
2272
+
2273
+ say('');
2274
+ Bridge.reportSize();
2275
+ }
2276
+
2277
+ function renderServers() {
2278
+ var sel = el('agent');
2279
+ sel.textContent = '';
2280
+ var online = servers.filter(function (s) { return s.agent_id; });
2281
+ if (online.length === 0) {
2282
+ var opt = document.createElement('option');
2283
+ opt.value = '';
2284
+ opt.textContent = 'No agent-enabled server on this account';
2285
+ sel.appendChild(opt);
2286
+ sel.disabled = true;
2287
+ el('deploy').disabled = true;
2288
+ return;
2289
+ }
2290
+ online.forEach(function (s) {
2291
+ var opt = document.createElement('option');
2292
+ opt.value = String(s.agent_id);
2293
+ var label = String(s.hostname || s.agent_id);
2294
+ if (s.status && String(s.status).toLowerCase() !== 'online') {
2295
+ label += ' (' + String(s.status) + ')';
2296
+ }
2297
+ opt.textContent = label;
2298
+ sel.appendChild(opt);
2299
+ });
2300
+ sel.disabled = false;
2301
+ el('deploy').disabled = false;
2302
+ }
2303
+
2304
+ // ── deploy + watch ──────────────────────────────────────────────────────
2305
+ function startDeploy() {
2306
+ if (busy || !picked) { return; }
2307
+ var agentId = el('agent').value;
2308
+ if (!agentId) { say('Pick a server with the Impreza agent installed.', true); return; }
2309
+
2310
+ var args = { app_name: picked.name, agent_id: agentId };
2311
+ var domain = el('domain').value.trim();
2312
+ if (domain) { args.domain = domain; }
2313
+ if (el('onion').checked) { args.onion = true; }
2314
+
2315
+ busy = true;
2316
+ el('deploy').disabled = true;
2317
+ el('form').hidden = true;
2318
+ el('progress').hidden = false;
2319
+ el('steps').textContent = '';
2320
+ setChip('pending', 'Deploying');
2321
+ step('work', 'sent', 'Asked the server to deploy ' + String(picked.display_name || picked.name) + '.');
2322
+
2323
+ Bridge.callTool('impreza_deploy_catalog_app', args)
2324
+ .then(function (d) {
2325
+ // A host that speaks the Tasks extension hands back a task handle
2326
+ // instead of the deployment; either way the deployment's own status is
2327
+ // what we watch, so both shapes lead to the same place.
2328
+ deploymentId = (d && (d.id || d.deployment_id)) || null;
2329
+ if (d && d.taskId && !deploymentId) {
2330
+ step('work', 'task', 'The host is tracking this as a task; watching the deployment directly.');
2331
+ }
2332
+ if (d && d.domain) {
2333
+ showUrl(d.domain);
2334
+ }
2335
+ step('work', 'queued', 'Accepted. Pulling images and starting the app — usually a few minutes.');
2336
+ watch();
2337
+ })
2338
+ .catch(function (e) {
2339
+ busy = false;
2340
+ setChip('error', 'Failed');
2341
+ step('bad', 'error', e.message || 'The deploy was refused.');
2342
+ el('again').hidden = false;
2343
+ });
2344
+ }
2345
+
2346
+ function showUrl(domain) {
2347
+ var u = String(domain || '');
2348
+ if (!u) { return; }
2349
+ if (u.indexOf('http') !== 0) { u = 'https://' + u; }
2350
+ el('url').textContent = u;
2351
+ el('result').hidden = false;
2352
+ el('open').hidden = !Bridge.canOpenLinks();
2353
+ Bridge.reportSize();
2354
+ }
2355
+
2356
+ var seenStatus = '';
2357
+ function watch() {
2358
+ clearTimeout(watchTimer);
2359
+ watchTicks++;
2360
+ // ~5 minutes at 6s. A deploy that has not settled by then has a real
2361
+ // problem, and saying so beats polling forever.
2362
+ if (watchTicks > 50) {
2363
+ setChip('pending', 'Still working');
2364
+ step('work', 'slow', 'Still not settled. Check impreza_list_deployments in a moment.');
2365
+ busy = false;
2366
+ el('again').hidden = false;
2367
+ return;
2368
+ }
2369
+
2370
+ var agentId = el('agent').value;
2371
+ Bridge.callTool('impreza_list_deployments', agentId ? { agent_id: agentId } : {})
2372
+ .then(function (d) {
2373
+ var rows = (d && d.deployments) || [];
2374
+ var row = null;
2375
+ if (deploymentId) {
2376
+ row = rows.filter(function (r) { return r.id === deploymentId; })[0] || null;
2377
+ }
2378
+ if (!row) {
2379
+ // No id to match on: the newest row for this app is the one we just
2380
+ // asked for.
2381
+ row = rows
2382
+ .filter(function (r) { return r.app_name === picked.name; })
2383
+ .sort(function (a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); })[0] || null;
2384
+ }
2385
+ if (!row) {
2386
+ watchTimer = setTimeout(watch, 6000);
2387
+ return;
2388
+ }
2389
+ deploymentId = row.id || deploymentId;
2390
+ var st = String(row.status || '').toLowerCase();
2391
+ if (st && st !== seenStatus) {
2392
+ seenStatus = st;
2393
+ if (st !== 'running' && st !== 'failed') { step('work', st, 'Status is now ' + st + '.'); }
2394
+ }
2395
+ if (row.domain) { showUrl(row.domain); }
2396
+
2397
+ if (st === 'running') {
2398
+ setChip('ok', 'Running');
2399
+ step('ok', 'done', 'Deployed and healthy.');
2400
+ busy = false;
2401
+ el('again').hidden = false;
2402
+ return;
2403
+ }
2404
+ if (st === 'failed') {
2405
+ setChip('error', 'Failed');
2406
+ step('bad', 'failed', String(row.last_error || 'The deploy failed.').slice(0, 240));
2407
+ busy = false;
2408
+ el('again').hidden = false;
2409
+ return;
2410
+ }
2411
+ watchTimer = setTimeout(watch, 6000);
2412
+ })
2413
+ .catch(function () { watchTimer = setTimeout(watch, 8000); });
2414
+ }
2415
+
2416
+ function reset() {
2417
+ clearTimeout(watchTimer);
2418
+ watchTicks = 0;
2419
+ seenStatus = '';
2420
+ deploymentId = null;
2421
+ busy = false;
2422
+ picked = null;
2423
+ el('progress').hidden = true;
2424
+ el('result').hidden = true;
2425
+ el('open').hidden = true;
2426
+ el('again').hidden = true;
2427
+ el('form').hidden = true;
2428
+ el('pick').hidden = false;
2429
+ el('deploy').disabled = false;
2430
+ el('chip').hidden = true;
2431
+ el('title').textContent = 'Deploy an app';
2432
+ el('sub').textContent = apps.length + ' apps in the catalogue';
2433
+ renderTiles();
2434
+ }
2435
+
2436
+ // ── wiring ──────────────────────────────────────────────────────────────
2437
+ el('search').addEventListener('input', renderTiles);
2438
+ el('deploy').addEventListener('click', startDeploy);
2439
+ el('back').addEventListener('click', reset);
2440
+ el('again').addEventListener('click', reset);
2441
+ el('copyUrl').addEventListener('click', function () { Bridge.copy(el('url').textContent, this); });
2442
+ el('open').addEventListener('click', function () {
2443
+ Bridge.openLink(el('url').textContent)
2444
+ .catch(function () { say('The host declined to open the link.', true); });
2445
+ });
2446
+
2447
+ Bridge.start({
2448
+ name: 'impreza-deploy-wizard',
2449
+ onResult: function (d) {
2450
+ apps = (d && d.apps) || [];
2451
+ el('pick').hidden = false;
2452
+ el('sub').textContent = apps.length + ' apps in the catalogue';
2453
+ renderTiles();
2454
+ // One enrichment call: the deploy needs a target, and only the server
2455
+ // list maps this account's agents.
2456
+ Bridge.callTool('impreza_list_servers', {})
2457
+ .then(function (r) { servers = (r && r.servers) || []; renderServers(); })
2458
+ .catch(function () {
2459
+ servers = [];
2460
+ renderServers();
2461
+ say('Could not list your servers — reload the panel to try again.', true);
2462
+ });
2463
+ },
2464
+ onFail: function (text) {
2465
+ el('sub').textContent = '';
2466
+ setChip('error', 'Unavailable');
2467
+ say(String(text || 'Could not load the catalogue.'), true);
2468
+ }
2469
+ });
2470
+ })();
2471
+ </script>
2472
+ </body>
2473
+ </html>
2474
2474
  `;
2475
2475
  /** md5 of the canonical lib/ui/deploy-wizard.html, LF-normalised. */
2476
2476
  export const DEPLOY_WIZARD_HTML_MD5 = '59ce1c546d8aa4b99aead60b0e5795b3';