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/README.md +25 -1
- package/dist/server.js +137 -1
- package/dist/server.js.map +1 -1
- package/dist/ui-assets.js +2455 -2455
- package/package.json +1 -1
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';
|