impreza-mcp 0.55.0 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/ui-assets.d.ts +6 -6
- package/dist/ui-assets.js +23 -17
- package/dist/ui-assets.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -355,7 +355,7 @@ Static npm sites: choose build_strategy=node_npm_static with a Git/context sourc
|
|
|
355
355
|
|
|
356
356
|
## Status
|
|
357
357
|
|
|
358
|
-
**Package version: 0.55.
|
|
358
|
+
**Package version: 0.55.1.** The tool catalog covers app deployment plus account +
|
|
359
359
|
crypto balance, catalog + ordering, domains/DNS + registration, invoices, VPS
|
|
360
360
|
lifecycle with snapshots and backups, dedicated / bare-metal servers, plan
|
|
361
361
|
upgrades, and Titan / Google Workspace mailboxes — with a setup wizard that
|
package/dist/ui-assets.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export declare const TOPUP_CARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Top-up payment</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_topup_payment.\n\n Why this exists: a crypto address is the one value in this product that plain\n chat text handles badly. Text gets re-wrapped, truncated, summarised, and \u2014 in\n the prompt-injection case \u2014 rewritten by a model that read a hostile page. Here\n the address arrives in `structuredContent`, which never passes through the\n model's output, and is rendered through `textContent`, never `innerHTML`.\n\n The shared rules (no network, no innerHTML, never inject the host's font CSS)\n live in _bridge.js and are enforced by tools/verify_mcp_apps.php.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* An address is checked at its ENDS. Grouping it and dimming the middle is\n what makes \"did I paste the right one?\" answerable at a glance. */\n .addr { display: flex; flex-wrap: wrap; gap: 0 7px; line-height: 1.6; }\n .g { color: var(--color-text-secondary); }\n .g.edge { color: var(--color-text-primary); font-weight: 600; }\n\n /* Only rendered when there is a real choice of rail. */\n .rails { display: flex; flex-wrap: wrap; gap: 6px; }\n .rail {\n padding: 5px 11px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n }\n .rail[aria-selected=\"true\"] {\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n }\n #paymentQR canvas { display:block; width:min(100%,256px); height:auto; background:#fff; }\n #paymentQR { margin:12px 0; max-width:256px; }\n .value { overflow-wrap:anywhere; }\n .actions { flex-wrap:wrap; }\n .rail:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n</style>\n</head>\n<body>\n<div class=\"card\">\n <div class=\"head\">\n <div>\n <div class=\"total\" id=\"total\">\u2014</div>\n <div class=\"sub\" id=\"sub\">Waiting for payment details\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"pending\">Pending</div>\n </div>\n\n <div class=\"rails\" id=\"rails\" hidden role=\"tablist\" aria-label=\"Payment currency\"></div>\n\n <div id=\"pay\" hidden>\n <div class=\"field\" style=\"margin-bottom:10px\">\n <div class=\"field-head\">\n <span class=\"label\">Send exactly</span>\n <button class=\"copy\" id=\"copyAmount\" type=\"button\">Copy</button>\n </div>\n <div class=\"value\" id=\"amount\"></div>\n <div class=\"sub\" id=\"rate\" hidden></div>\n </div>\n\n <!-- Address last, closest to the action: a wrong amount can be topped up,\n a wrong address cannot be recovered. -->\n <div class=\"field\">\n <div class=\"field-head\">\n <span class=\"label\" id=\"addrLabel\">To this address</span>\n <button class=\"copy\" id=\"copyAddr\" type=\"button\">Copy</button>\n </div>\n <div class=\"value addr\" id=\"addr\"></div>\n </div>\n </div>\n\n <div id=\"paymentQR\" hidden role=\"img\" aria-label=\"Payment QR code: verify the exact address and amount in your wallet\"></div>\n <p class=\"note\" id=\"paymentExpiry\" role=\"status\" aria-live=\"polite\"></p>\n <p class=\"note\" id=\"qrAvailability\">QR is unavailable for these payment details. Copy the address and exact amount.</p>\n\n <div class=\"rest\" id=\"rest\" hidden></div>\n\n <div class=\"actions\">\n <button class=\"btn\" id=\"check\" type=\"button\" hidden>Check payment</button>\n <button class=\"btn\" id=\"wallet\" type=\"button\" hidden>Open in wallet</button>\n <span class=\"note\" id=\"status\"></span>\n </div>\n\n <div class=\"note\" id=\"verify\" hidden>\n Check the <strong>highlighted first and last groups</strong> of the address against\n your wallet before sending. Send the exact amount \u2014 a short payment credits short.\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n var options = [];\n var picked = 0;\n var invoiceId = null;\n var busy = false;\n var generation = 0, current = null, timer = null, serverTime = null;\n var deadlines = new Map();\n // Build-time replacement only after a licensed, independently decoded encoder\n // is admitted. No global/provider-supplied function, SVG or remote QR service.\n var paymentEncoder = null;\n // F58.8: the card's own encoder (below) replaces only the null default; a\n // harness that injects a spy above keeps its spy.\n if (paymentEncoder === null) {\n paymentEncoder =\n // QR-ENCODER-BEGIN\n (function () {\n // QR Code Model 2 (ISO/IEC 18004), original self-contained implementation for this card:\n // 8-bit byte mode, error correction level M, versions 1-10, the eight data masks chosen by\n // the standard penalty rules. No dependency, network, DOM or global. Returns a boolean\n // matrix (true = dark) without the quiet zone (drawQr adds four modules). Anything outside\n // printable ASCII or above the version 10-M byte capacity (213) throws: the card then shows\n // no code at all, never a decorative or truncated one.\n // Per version: [EC codewords per block, blocks in group 1, data codewords each, group 2 blocks, data each].\n var BLOCKS = [null, [10,1,16,0,0], [16,1,28,0,0], [26,1,44,0,0], [18,2,32,0,0], [24,2,43,0,0],\n [16,4,27,0,0], [18,4,31,0,0], [22,2,38,2,39], [22,3,36,2,37], [26,4,43,1,44]];\n var ALIGN = [null, [], [6,18], [6,22], [6,26], [6,30], [6,34], [6,22,38], [6,24,42], [6,26,46], [6,28,50]];\n var EXP = [], LOG = [];\n for (var i = 0, x = 1; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 256) { x ^= 0x11d; } }\n for (i = 255; i < 512; i++) { EXP[i] = EXP[i - 255]; }\n function mul(a, b) { return a && b ? EXP[LOG[a] + LOG[b]] : 0; }\n function rs(data, n) {\n var g = [1];\n for (var k = 0; k < n; k++) {\n var next = []; for (var j = 0; j <= g.length; j++) { next[j] = 0; }\n for (j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= mul(g[j], EXP[k]); }\n g = next;\n }\n var r = []; for (k = 0; k < n; k++) { r[k] = 0; }\n data.forEach(function (d) {\n var f = d ^ r.shift(); r.push(0);\n for (var j = 0; j < n; j++) { r[j] ^= mul(g[j + 1], f); }\n });\n return r;\n }\n function bch(value, bits, poly, polyBits) {\n var v = value << (polyBits - 1);\n for (var b = bits + polyBits - 2; b >= polyBits - 1; b--) { if ((v >>> b) & 1) { v ^= poly << (b - polyBits + 1); } }\n return (value << (polyBits - 1)) | v;\n }\n function formatBits(mask) { return bch(mask, 5, 0x537, 11) ^ 0x5412; } // level M = 00\n function versionBits(version) { return bch(version, 6, 0x1f25, 13); }\n function codewords(bytes, version) {\n var spec = BLOCKS[version], total = spec[1] * spec[2] + spec[3] * spec[4];\n var bits = [], push = function (value, n) { for (var b = n - 1; b >= 0; b--) { bits.push((value >>> b) & 1); } };\n push(4, 4); push(bytes.length, version < 10 ? 8 : 16);\n bytes.forEach(function (b) { push(b, 8); });\n if (bits.length > total * 8) { throw new Error('capacity'); }\n push(0, Math.min(4, total * 8 - bits.length));\n while (bits.length % 8) { bits.push(0); }\n var data = [];\n for (var k = 0; k < bits.length; k += 8) { data.push(parseInt(bits.slice(k, k + 8).join(''), 2)); }\n for (var pad = 0xec; data.length < total; pad ^= 0xec ^ 0x11) { data.push(pad); }\n var blocks = [], at = 0;\n for (var group = 0; group < 2; group++) {\n for (var c = 0; c < spec[1 + group * 2]; c++) {\n var part = data.slice(at, at + spec[2 + group * 2]); at += part.length;\n blocks.push({ data: part, ec: rs(part, spec[0]) });\n }\n }\n var out = [], longest = Math.max(spec[2], spec[4]);\n for (k = 0; k < longest; k++) { blocks.forEach(function (bl) { if (k < bl.data.length) { out.push(bl.data[k]); } }); }\n for (k = 0; k < spec[0]; k++) { blocks.forEach(function (bl) { out.push(bl.ec[k]); }); }\n return out;\n }\n function encode(text) {\n if (typeof text !== 'string' || !/^[\\x21-\\x7e]{1,213}$/.test(text)) { throw new Error('input'); }\n var bytes = [];\n for (var k = 0; k < text.length; k++) { bytes.push(text.charCodeAt(k)); }\n var version = 1;\n while (version <= 10) {\n var spec = BLOCKS[version], cap = (spec[1] * spec[2] + spec[3] * spec[4]) * 8;\n if (4 + (version < 10 ? 8 : 16) + 8 * bytes.length <= cap) { break; }\n version++;\n }\n if (version > 10) { throw new Error('capacity'); }\n var size = 17 + 4 * version, m = [], fixed = [];\n for (var y = 0; y < size; y++) { m.push([]); fixed.push([]); for (var x0 = 0; x0 < size; x0++) { m[y].push(false); fixed[y].push(false); } }\n function set(r, c, dark) { m[r][c] = dark; fixed[r][c] = true; }\n [[0, 0], [0, size - 7], [size - 7, 0]].forEach(function (o) {\n for (var dy = -1; dy <= 7; dy++) {\n for (var dx = -1; dx <= 7; dx++) {\n var r = o[0] + dy, c = o[1] + dx;\n if (r < 0 || c < 0 || r >= size || c >= size) { continue; }\n var ring = dy >= 0 && dy <= 6 && dx >= 0 && dx <= 6 && (dy === 0 || dy === 6 || dx === 0 || dx === 6);\n set(r, c, ring || (dy >= 2 && dy <= 4 && dx >= 2 && dx <= 4));\n }\n }\n });\n for (var t = 8; t < size - 8; t++) { set(6, t, t % 2 === 0); set(t, 6, t % 2 === 0); }\n var centers = ALIGN[version], last = centers.length - 1;\n centers.forEach(function (r, a) {\n centers.forEach(function (c, b) {\n if ((a === 0 && b === 0) || (a === 0 && b === last) || (a === last && b === 0)) { return; }\n for (var dy = -2; dy <= 2; dy++) {\n for (var dx = -2; dx <= 2; dx++) { set(r + dy, c + dx, Math.max(Math.abs(dy), Math.abs(dx)) !== 1); }\n }\n });\n });\n for (t = 0; t < 9; t++) { if (!fixed[8][t]) { set(8, t, false); } if (!fixed[t][8]) { set(t, 8, false); } }\n for (t = 0; t < 8; t++) { set(8, size - 1 - t, false); set(size - 1 - t, 8, false); }\n set(size - 8, 8, true);\n if (version >= 7) {\n for (t = 0; t < 18; t++) { set(Math.floor(t / 3), size - 11 + t % 3, false); set(size - 11 + t % 3, Math.floor(t / 3), false); }\n }\n var stream = [];\n codewords(bytes, version).forEach(function (cw) { for (var b = 7; b >= 0; b--) { stream.push((cw >>> b) & 1); } });\n var index = 0;\n for (var right = size - 1; right >= 1; right -= 2) {\n if (right === 6) { right = 5; }\n for (var vert = 0; vert < size; vert++) {\n for (var j = 0; j < 2; j++) {\n var col = right - j, row = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;\n if (!fixed[row][col]) { m[row][col] = index < stream.length ? stream[index] === 1 : false; index++; }\n }\n }\n }\n var MASKS = [\n function (r, c) { return (r + c) % 2 === 0; }, function (r) { return r % 2 === 0; },\n function (r, c) { return c % 3 === 0; }, function (r, c) { return (r + c) % 3 === 0; },\n function (r, c) { return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0; },\n function (r, c) { return (r * c) % 2 + (r * c) % 3 === 0; },\n function (r, c) { return ((r * c) % 2 + (r * c) % 3) % 2 === 0; },\n function (r, c) { return ((r + c) % 2 + (r * c) % 3) % 2 === 0; }];\n function finish(mask) {\n var q = m.map(function (row, r) { return row.map(function (dark, c) { return fixed[r][c] ? dark : dark !== MASKS[mask](r, c); }); });\n var f = formatBits(mask);\n for (var k = 0; k < 15; k++) {\n var on = ((f >>> k) & 1) === 1;\n // First copy around the top-left finder (bits 0-7 down column 8, skipping the timing row; 8-14 along row 8).\n if (k < 6) { q[k][8] = on; } else if (k < 8) { q[k + 1][8] = on; } else if (k === 8) { q[8][7] = on; } else { q[8][14 - k] = on; }\n // Second copy: bits 0-7 along row 8 from the right edge, bits 8-14 up column 8 from the bottom.\n if (k < 8) { q[8][size - 1 - k] = on; } else { q[size - 15 + k][8] = on; }\n }\n q[size - 8][8] = true;\n if (version >= 7) {\n var v = versionBits(version);\n for (k = 0; k < 18; k++) {\n var bit = ((v >>> k) & 1) === 1;\n q[Math.floor(k / 3)][size - 11 + k % 3] = bit; q[size - 11 + k % 3][Math.floor(k / 3)] = bit;\n }\n }\n return q;\n }\n function penalty(q) {\n var score = 0, dark = 0, n = q.length;\n function line(get) {\n var s = 0;\n for (var a = 0; a < n; a++) {\n var run = 1, seq = [];\n for (var b = 0; b < n; b++) {\n seq.push(get(a, b) ? 1 : 0);\n if (b > 0) { if (get(a, b) === get(a, b - 1)) { run++; } else { if (run >= 5) { s += run - 2; } run = 1; } }\n }\n if (run >= 5) { s += run - 2; }\n var str = seq.join('');\n ['10111010000', '00001011101'].forEach(function (p) { for (var at = str.indexOf(p); at >= 0; at = str.indexOf(p, at + 1)) { s += 40; } });\n }\n return s;\n }\n score += line(function (a, b) { return q[a][b]; }) + line(function (a, b) { return q[b][a]; });\n for (var r = 0; r < n; r++) {\n for (var c = 0; c < n; c++) {\n if (q[r][c]) { dark++; }\n if (r < n - 1 && c < n - 1 && q[r][c] === q[r][c + 1] && q[r][c] === q[r + 1][c] && q[r][c] === q[r + 1][c + 1]) { score += 3; }\n }\n }\n return score + Math.floor(Math.abs(dark * 100 / (n * n) - 50) / 5) * 10;\n }\n var best = null, bestScore = Infinity;\n for (var mask = 0; mask < 8; mask++) {\n var candidate = finish(mask), s = penalty(candidate);\n if (s < bestScore) { best = candidate; bestScore = s; }\n }\n return best;\n }\n encode.parts = Object.freeze({ rs: rs, formatBits: formatBits, versionBits: versionBits, codewords: codewords });\n return Object.freeze(encode);\n })();\n // QR-ENCODER-END\n }\n function monotonic() { return typeof performance !== 'undefined' ? performance.now() : NaN; }\n function absolute(value) {\n if (typeof value !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/.test(value)) { return NaN; }\n var ms = Date.parse(value);\n return isFinite(ms) && new Date(ms).toISOString().slice(0,19) === value.slice(0,19) ? ms : NaN;\n }\n function clearPayment() {\n if (timer !== null) { clearInterval(timer); timer = null; }\n current = null;\n el('paymentQR').textContent = ''; el('paymentQR').hidden = true;\n el('paymentExpiry').textContent = '';\n el('wallet').hidden = true;\n el('copyAddr').disabled = true; el('copyAmount').disabled = true;\n }\n function paymentInfo(o) {\n if (!o || typeof o.crypto !== 'string' || !/^[A-Z0-9]{2,12}$/.test(o.crypto) ||\n typeof o.address !== 'string' || !/^[A-Za-z0-9]{6,128}$/.test(o.address) ||\n typeof o.crypto_amount !== 'string' || o.crypto_amount.length > 128) { return null; }\n var digits = o.crypto === 'BTC' ? 8 : o.crypto === 'XMR' ? 12 : 30;\n if (!new RegExp('^(0|[1-9][0-9]*)(\\\\.[0-9]{1,' + digits + '})?$').test(o.crypto_amount) || !/[1-9]/.test(o.crypto_amount)) { return null; }\n var uri = null;\n if (o.crypto === 'BTC' || o.crypto === 'XMR') {\n if (o.network != null && (typeof o.network !== 'string' || !(o.crypto === 'BTC' ? /^(Bitcoin|BTC|mainnet)$/i : /^(Monero|XMR|mainnet)$/i).test(o.network))) { return null; }\n var expected = (o.crypto === 'BTC' ? 'bitcoin:' : 'monero:') + o.address +\n (o.crypto === 'BTC' ? '?amount=' : '?tx_amount=') + o.crypto_amount;\n if (o.address.length < 14 || o.payment_uri !== expected) { return null; }\n uri = expected;\n }\n var expiry = absolute(o.expires_at), now = monotonic(), deadline = null;\n if (o.expiry_state === 'absolute' && isFinite(serverTime) && isFinite(expiry) && isFinite(now)) {\n deadline = now + expiry - serverTime;\n var key = JSON.stringify([invoiceId,o.crypto,o.network,o.address,o.crypto_amount,o.expires_at]);\n // Replayed identical proposals cannot restart their old payment window.\n if (deadlines.has(key)) { deadline = Math.min(deadline, deadlines.get(key)); }\n if (!deadlines.has(key) && deadlines.size >= 64) { deadline = null; }\n else { deadlines.set(key, deadline); }\n }\n return { option:o, uri:uri, deadline:deadline };\n }\n function activePayment() {\n if (current && current.deadline !== null && current.deadline <= monotonic()) { expirePayment(); }\n return current;\n }\n function expirePayment() {\n clearPayment(); el('pay').hidden = true; el('verify').hidden = true;\n el('paymentExpiry').textContent = 'Payment window expired. Check status or request fresh details before sending.';\n Bridge.reportSize();\n }\n function drawQr(info) {\n if (!paymentEncoder || !info.uri || info.deadline === null) { return; }\n try {\n var matrix = paymentEncoder(info.uri), n = matrix.length;\n if (!Array.isArray(matrix) || n < 21 || n > 177 || n % 4 !== 1 || !Array.from(matrix).every(function(row) {\n return Array.isArray(row) && row.length === n && Array.from(row).every(function(cell) { return typeof cell === 'boolean'; });\n })) { return; }\n var canvas = document.createElement('canvas'), scale = 4;\n canvas.width = (n+8)*scale; canvas.height = (n+8)*scale;\n canvas.setAttribute('aria-hidden','true');\n var pen = canvas.getContext('2d'); if (!pen) { return; }\n pen.fillStyle = '#fff'; pen.fillRect(0,0,canvas.width,canvas.height);\n pen.fillStyle = '#000';\n matrix.forEach(function(row,y){row.forEach(function(on,x){if(on){pen.fillRect((x+4)*scale,(y+4)*scale,scale,scale);}});});\n el('paymentQR').appendChild(canvas); el('paymentQR').hidden = false;\n el('qrAvailability').hidden = true;\n } catch (e) { /* A missing encoder never substitutes a pretend QR or upstream error. */ }\n }\n function bindPayment(o) {\n clearPayment(); el('qrAvailability').hidden = false;\n current = paymentInfo(o);\n if (!current) {\n el('pay').hidden = true; el('verify').hidden = true;\n el('paymentExpiry').textContent = 'Payment details cannot be verified. Request fresh details before sending.';\n return;\n }\n el('copyAddr').disabled = false; el('copyAmount').disabled = false;\n if (current.deadline === null) {\n el('paymentExpiry').textContent = 'Payment expiry is unknown. No countdown is available; request fresh details before sending.';\n return;\n }\n function tick() {\n var info = activePayment(); if (!info) { return; }\n var seconds = Math.ceil((info.deadline-monotonic())/1000);\n el('paymentExpiry').textContent = 'Payment window: '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0')+' remaining.';\n }\n tick(); if (!current) { return; }\n drawQr(current); timer = setInterval(tick,1000);\n el('wallet').hidden = !(current.uri && Bridge.canOpenLinks());\n }\n\n function setChip(state, text) {\n var chip = el('chip');\n chip.dataset.state = state;\n chip.textContent = text;\n }\n\n function money(amount, currency) {\n var n = Number(amount);\n var shown = isFinite(n) ? n.toFixed(2) : String(amount == null ? '' : amount);\n return shown + ' ' + String(currency || '');\n }\n\n // Groups of four. The FIRST four and the LAST four characters are the anchors\n // a person actually checks against their wallet, so they are taken as the\n // edges before the middle is chunked \u2014 grouping left-to-right and marking the\n // final group would leave a two-character anchor on many addresses, which is\n // a weaker thing to ask someone to verify.\n function renderAddress(address) {\n var box = el('addr');\n box.textContent = '';\n var s = String(address || '');\n if (s === '') { return; }\n\n var parts;\n if (s.length <= 12) {\n parts = [{ t: s, edge: true }];\n } else {\n parts = [{ t: s.slice(0, 4), edge: true }];\n (s.slice(4, -4).match(/.{1,4}/g) || []).forEach(function (g) {\n parts.push({ t: g, edge: false });\n });\n parts.push({ t: s.slice(-4), edge: true });\n }\n\n parts.forEach(function (p) {\n var span = document.createElement('span');\n span.className = 'g' + (p.edge ? ' edge' : '');\n span.textContent = p.t;\n box.appendChild(span);\n });\n }\n\n function renderRails() {\n var box = el('rails');\n box.textContent = '';\n if (options.length < 2) { box.hidden = true; return; }\n box.hidden = false;\n options.forEach(function (o, i) {\n var b = document.createElement('button');\n b.type = 'button';\n b.className = 'rail';\n b.setAttribute('role', 'tab');\n b.setAttribute('aria-selected', i === picked ? 'true' : 'false');\n b.textContent = String(o.crypto || o.rail || '?');\n b.addEventListener('click', function () { generation++; busy = false; el('check').disabled = false; picked = i; renderOption(); renderRails(); });\n box.appendChild(b);\n });\n }\n\n function renderOption() {\n clearPayment();\n var o = options[picked];\n if (!o) { el('pay').hidden = true; el('verify').hidden = true; return; }\n el('pay').hidden = false;\n el('verify').hidden = false;\n\n el('amount').textContent = String(o.crypto_amount || '') + ' ' + String(o.crypto || '');\n el('addrLabel').textContent = o.network\n ? 'To this address \u00B7 ' + String(o.network)\n : 'To this address';\n renderAddress(o.address);\n\n var rate = el('rate');\n var bits = [];\n if (o.rate) { bits.push('Rate ' + String(o.rate)); }\n\n if (o.note) { bits.push(String(o.note)); }\n rate.textContent = bits.join(' \u00B7 ');\n rate.hidden = bits.length === 0;\n\n // A wallet hand-off is only offered when the host says it can open links.\n bindPayment(o);\n Bridge.reportSize();\n }\n\n function renderRest(available) {\n var box = el('rest');\n if (!available) { box.hidden = true; return; }\n // Whatever is already a tab is not \"also payable in\" \u2014 repeating it reads\n // as a second, worse route to the same thing.\n var shown = {};\n options.forEach(function (o) { shown[String(o.crypto || '').toUpperCase()] = true; });\n var more = [];\n var add = function (c) {\n var k = String(c).toUpperCase();\n if (!shown[k]) { shown[k] = true; more.push(k); }\n };\n (available.tronpay || []).forEach(add);\n (available.altcoins_via_fixedfloat || []).forEach(add);\n if (more.length === 0) { box.hidden = true; return; }\n box.hidden = false;\n box.textContent = 'Also payable in ' + more.slice(0, 12).join(', ')\n + (more.length > 12 ? ' and more' : '')\n + ' \u2014 ask for that coin to get its own address.';\n }\n\n function render(data) {\n generation++; busy = false; el('check').disabled = false; clearPayment();\n if (!data || typeof data !== 'object' || !Number.isSafeInteger(data.invoice_id) || data.invoice_id <= 0) { fail(); return; }\n // Only an explicit pending proposal may offer payment actions. Cancelled,\n // refunded, absent and unknown states never inherit an old payable card.\n if (data.status !== 'pending' && data.status !== 'paid') { fail(); return; }\n invoiceId = data.invoice_id; serverTime = absolute(data.server_time);\n el('status').textContent = ''; el('status').className = 'note'; el('qrAvailability').hidden = false;\n\n el('total').textContent = money(data.fiat_amount, data.currency);\n el('sub').textContent = invoiceId != null ? ('Top-up invoice #' + String(invoiceId)) : '';\n\n var paid = String(data.status || '').toLowerCase() === 'paid';\n setChip(paid ? 'paid' : 'pending', paid ? 'Paid' : 'Awaiting payment');\n\n options = Array.isArray(data.payment_options) ? data.payment_options : [];\n if (picked >= options.length) { picked = 0; }\n\n if (paid) {\n el('qrAvailability').hidden = true;\n el('pay').hidden = true;\n el('rails').hidden = true;\n el('verify').hidden = true;\n el('rest').hidden = true;\n el('wallet').hidden = true;\n el('status').textContent = data.balance != null\n ? ('Credited. Account balance is now ' + money(data.balance, data.currency) + '.')\n : 'Credited to the account balance.';\n } else {\n renderRails();\n renderOption();\n renderRest(data.available);\n }\n\n el('check').hidden = paid || invoiceId == null;\n Bridge.reportSize();\n }\n\n function fail(text) {\n generation++; busy = false; invoiceId = null; options = []; clearPayment();\n el('pay').hidden = true; el('rails').hidden = true; el('check').hidden = true; el('verify').hidden = true; el('rest').hidden = true;\n el('addr').textContent = ''; el('amount').textContent = '';\n setChip('error', 'Unavailable');\n el('sub').textContent = '';\n var s = el('status');\n s.className = 'note warn';\n s.textContent = 'Payment details unavailable. Request fresh details.';\n Bridge.reportSize();\n }\n\n el('copyAmount').addEventListener('click', function () {\n var o = options[picked];\n if (o && activePayment()) { Bridge.copy(o.crypto_amount, this); }\n });\n el('copyAddr').addEventListener('click', function () {\n var o = options[picked];\n if (o && activePayment()) { Bridge.copy(o.address, this); }\n });\n\n el('wallet').addEventListener('click', function () {\n var o = options[picked];\n var info = activePayment();\n if (!o || !info || !info.uri || info.deadline === null) { return; }\n var expected = generation;\n Bridge.openLink(info.uri).catch(function () {\n if (expected !== generation) { return; }\n el('status').textContent = 'The host declined to open the wallet link \u2014 copy the address instead.';\n });\n });\n\n // The one server call this panel makes, and the mildest one available: a\n // read-scope status poll on the caller's own invoice.\n el('check').addEventListener('click', function () {\n if (busy || invoiceId == null) { return; }\n busy = true;\n var expected = generation, checkedInvoice = invoiceId;\n var btn = this;\n btn.disabled = true;\n el('status').className = 'note';\n el('status').textContent = 'Checking\u2026';\n Bridge.callTool('impreza_topup_status', { invoice_id: invoiceId })\n .then(function (data) {\n if (expected !== generation || checkedInvoice !== invoiceId) { return; }\n if (data && String(data.status || '').toLowerCase() === 'paid') {\n render({\n invoice_id: invoiceId,\n fiat_amount: data.amount != null ? data.amount : data.fiat_amount,\n currency: data.currency,\n status: 'paid',\n balance: data.balance\n });\n } else if (data && data.status === 'pending') {\n el('status').textContent = 'Not confirmed yet. On-chain confirmation usually takes a few minutes.';\n } else {\n // A cancelled/refunded/unknown result cannot leave payment actions\n // from the previous pending snapshot available.\n fail();\n }\n })\n .catch(function (e) {\n if (expected !== generation) { return; }\n el('status').className = 'note warn';\n el('status').textContent = 'The check failed. Try again in a moment.';\n })\n .then(function () {\n if (expected !== generation) { return; }\n busy = false;\n btn.disabled = false;\n Bridge.reportSize();\n });\n });\n\n Bridge.start({ name: 'impreza-topup-card', onResult: render, onFail: fail });\n})();\n</script>\n</body>\n</html>\n";
|
|
1
|
+
export declare const TOPUP_CARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Top-up payment</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_topup_payment.\n\n Why this exists: a crypto address is the one value in this product that plain\n chat text handles badly. Text gets re-wrapped, truncated, summarised, and \u2014 in\n the prompt-injection case \u2014 rewritten by a model that read a hostile page. Here\n the address arrives in `structuredContent`, which never passes through the\n model's output, and is rendered through `textContent`, never `innerHTML`.\n\n The shared rules (no network, no innerHTML, never inject the host's font CSS)\n live in _bridge.js and are enforced by tools/verify_mcp_apps.php.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* An address is checked at its ENDS. Grouping it and dimming the middle is\n what makes \"did I paste the right one?\" answerable at a glance. */\n .addr { display: flex; flex-wrap: wrap; gap: 0 7px; line-height: 1.6; }\n .g { color: var(--color-text-secondary); }\n .g.edge { color: var(--color-text-primary); font-weight: 600; }\n\n /* Only rendered when there is a real choice of rail. */\n .rails { display: flex; flex-wrap: wrap; gap: 6px; }\n .rail {\n padding: 5px 11px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n }\n .rail[aria-selected=\"true\"] {\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n }\n #paymentQR canvas { display:block; width:min(100%,256px); height:auto; background:#fff; }\n #paymentQR { margin:12px 0; max-width:256px; }\n .value { overflow-wrap:anywhere; }\n .actions { flex-wrap:wrap; }\n .amount-gap { margin-bottom: 10px; }\n .rail:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n</style>\n</head>\n<body>\n<div class=\"card\">\n <div class=\"head\">\n <div>\n <div class=\"total\" id=\"total\">\u2014</div>\n <div class=\"sub\" id=\"sub\">Waiting for payment details\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"pending\">Pending</div>\n </div>\n\n <div class=\"rails\" id=\"rails\" hidden role=\"tablist\" aria-label=\"Payment currency\"></div>\n\n <div id=\"pay\" hidden>\n <div class=\"field amount-gap\">\n <div class=\"field-head\">\n <span class=\"label\">Send exactly</span>\n <button class=\"copy\" id=\"copyAmount\" type=\"button\">Copy</button>\n </div>\n <div class=\"value\" id=\"amount\"></div>\n <div class=\"sub\" id=\"rate\" hidden></div>\n </div>\n\n <!-- Address last, closest to the action: a wrong amount can be topped up,\n a wrong address cannot be recovered. -->\n <div class=\"field\">\n <div class=\"field-head\">\n <span class=\"label\" id=\"addrLabel\">To this address</span>\n <button class=\"copy\" id=\"copyAddr\" type=\"button\">Copy</button>\n </div>\n <div class=\"value addr\" id=\"addr\"></div>\n </div>\n </div>\n\n <div id=\"paymentQR\" hidden role=\"img\" aria-label=\"Payment QR code: verify the exact address and amount in your wallet\"></div>\n <p class=\"note\" id=\"paymentExpiry\" role=\"status\" aria-live=\"polite\"></p>\n <p class=\"note\" id=\"qrAvailability\">QR is unavailable for these payment details. Copy the address and exact amount.</p>\n\n <div class=\"rest\" id=\"rest\" hidden></div>\n\n <div class=\"actions\">\n <button class=\"btn\" id=\"check\" type=\"button\" hidden>Check payment</button>\n <button class=\"btn\" id=\"wallet\" type=\"button\" hidden>Open in wallet</button>\n <span class=\"note\" id=\"status\"></span>\n </div>\n\n <div class=\"note\" id=\"verify\" hidden>\n Check the <strong>highlighted first and last groups</strong> of the address against\n your wallet before sending. Send the exact amount \u2014 a short payment credits short.\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.hostCapabilities || result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n var options = [];\n var picked = 0;\n var invoiceId = null;\n var busy = false;\n var generation = 0, current = null, timer = null, serverTime = null;\n var deadlines = new Map();\n // Build-time replacement only after a licensed, independently decoded encoder\n // is admitted. No global/provider-supplied function, SVG or remote QR service.\n var paymentEncoder = null;\n // F58.8: the card's own encoder (below) replaces only the null default; a\n // harness that injects a spy above keeps its spy.\n if (paymentEncoder === null) {\n paymentEncoder =\n // QR-ENCODER-BEGIN\n (function () {\n // QR Code Model 2 (ISO/IEC 18004), original self-contained implementation for this card:\n // 8-bit byte mode, error correction level M, versions 1-10, the eight data masks chosen by\n // the standard penalty rules. No dependency, network, DOM or global. Returns a boolean\n // matrix (true = dark) without the quiet zone (drawQr adds four modules). Anything outside\n // printable ASCII or above the version 10-M byte capacity (213) throws: the card then shows\n // no code at all, never a decorative or truncated one.\n // Per version: [EC codewords per block, blocks in group 1, data codewords each, group 2 blocks, data each].\n var BLOCKS = [null, [10,1,16,0,0], [16,1,28,0,0], [26,1,44,0,0], [18,2,32,0,0], [24,2,43,0,0],\n [16,4,27,0,0], [18,4,31,0,0], [22,2,38,2,39], [22,3,36,2,37], [26,4,43,1,44]];\n var ALIGN = [null, [], [6,18], [6,22], [6,26], [6,30], [6,34], [6,22,38], [6,24,42], [6,26,46], [6,28,50]];\n var EXP = [], LOG = [];\n for (var i = 0, x = 1; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 256) { x ^= 0x11d; } }\n for (i = 255; i < 512; i++) { EXP[i] = EXP[i - 255]; }\n function mul(a, b) { return a && b ? EXP[LOG[a] + LOG[b]] : 0; }\n function rs(data, n) {\n var g = [1];\n for (var k = 0; k < n; k++) {\n var next = []; for (var j = 0; j <= g.length; j++) { next[j] = 0; }\n for (j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= mul(g[j], EXP[k]); }\n g = next;\n }\n var r = []; for (k = 0; k < n; k++) { r[k] = 0; }\n data.forEach(function (d) {\n var f = d ^ r.shift(); r.push(0);\n for (var j = 0; j < n; j++) { r[j] ^= mul(g[j + 1], f); }\n });\n return r;\n }\n function bch(value, bits, poly, polyBits) {\n var v = value << (polyBits - 1);\n for (var b = bits + polyBits - 2; b >= polyBits - 1; b--) { if ((v >>> b) & 1) { v ^= poly << (b - polyBits + 1); } }\n return (value << (polyBits - 1)) | v;\n }\n function formatBits(mask) { return bch(mask, 5, 0x537, 11) ^ 0x5412; } // level M = 00\n function versionBits(version) { return bch(version, 6, 0x1f25, 13); }\n function codewords(bytes, version) {\n var spec = BLOCKS[version], total = spec[1] * spec[2] + spec[3] * spec[4];\n var bits = [], push = function (value, n) { for (var b = n - 1; b >= 0; b--) { bits.push((value >>> b) & 1); } };\n push(4, 4); push(bytes.length, version < 10 ? 8 : 16);\n bytes.forEach(function (b) { push(b, 8); });\n if (bits.length > total * 8) { throw new Error('capacity'); }\n push(0, Math.min(4, total * 8 - bits.length));\n while (bits.length % 8) { bits.push(0); }\n var data = [];\n for (var k = 0; k < bits.length; k += 8) { data.push(parseInt(bits.slice(k, k + 8).join(''), 2)); }\n for (var pad = 0xec; data.length < total; pad ^= 0xec ^ 0x11) { data.push(pad); }\n var blocks = [], at = 0;\n for (var group = 0; group < 2; group++) {\n for (var c = 0; c < spec[1 + group * 2]; c++) {\n var part = data.slice(at, at + spec[2 + group * 2]); at += part.length;\n blocks.push({ data: part, ec: rs(part, spec[0]) });\n }\n }\n var out = [], longest = Math.max(spec[2], spec[4]);\n for (k = 0; k < longest; k++) { blocks.forEach(function (bl) { if (k < bl.data.length) { out.push(bl.data[k]); } }); }\n for (k = 0; k < spec[0]; k++) { blocks.forEach(function (bl) { out.push(bl.ec[k]); }); }\n return out;\n }\n function encode(text) {\n if (typeof text !== 'string' || !/^[\\x21-\\x7e]{1,213}$/.test(text)) { throw new Error('input'); }\n var bytes = [];\n for (var k = 0; k < text.length; k++) { bytes.push(text.charCodeAt(k)); }\n var version = 1;\n while (version <= 10) {\n var spec = BLOCKS[version], cap = (spec[1] * spec[2] + spec[3] * spec[4]) * 8;\n if (4 + (version < 10 ? 8 : 16) + 8 * bytes.length <= cap) { break; }\n version++;\n }\n if (version > 10) { throw new Error('capacity'); }\n var size = 17 + 4 * version, m = [], fixed = [];\n for (var y = 0; y < size; y++) { m.push([]); fixed.push([]); for (var x0 = 0; x0 < size; x0++) { m[y].push(false); fixed[y].push(false); } }\n function set(r, c, dark) { m[r][c] = dark; fixed[r][c] = true; }\n [[0, 0], [0, size - 7], [size - 7, 0]].forEach(function (o) {\n for (var dy = -1; dy <= 7; dy++) {\n for (var dx = -1; dx <= 7; dx++) {\n var r = o[0] + dy, c = o[1] + dx;\n if (r < 0 || c < 0 || r >= size || c >= size) { continue; }\n var ring = dy >= 0 && dy <= 6 && dx >= 0 && dx <= 6 && (dy === 0 || dy === 6 || dx === 0 || dx === 6);\n set(r, c, ring || (dy >= 2 && dy <= 4 && dx >= 2 && dx <= 4));\n }\n }\n });\n for (var t = 8; t < size - 8; t++) { set(6, t, t % 2 === 0); set(t, 6, t % 2 === 0); }\n var centers = ALIGN[version], last = centers.length - 1;\n centers.forEach(function (r, a) {\n centers.forEach(function (c, b) {\n if ((a === 0 && b === 0) || (a === 0 && b === last) || (a === last && b === 0)) { return; }\n for (var dy = -2; dy <= 2; dy++) {\n for (var dx = -2; dx <= 2; dx++) { set(r + dy, c + dx, Math.max(Math.abs(dy), Math.abs(dx)) !== 1); }\n }\n });\n });\n for (t = 0; t < 9; t++) { if (!fixed[8][t]) { set(8, t, false); } if (!fixed[t][8]) { set(t, 8, false); } }\n for (t = 0; t < 8; t++) { set(8, size - 1 - t, false); set(size - 1 - t, 8, false); }\n set(size - 8, 8, true);\n if (version >= 7) {\n for (t = 0; t < 18; t++) { set(Math.floor(t / 3), size - 11 + t % 3, false); set(size - 11 + t % 3, Math.floor(t / 3), false); }\n }\n var stream = [];\n codewords(bytes, version).forEach(function (cw) { for (var b = 7; b >= 0; b--) { stream.push((cw >>> b) & 1); } });\n var index = 0;\n for (var right = size - 1; right >= 1; right -= 2) {\n if (right === 6) { right = 5; }\n for (var vert = 0; vert < size; vert++) {\n for (var j = 0; j < 2; j++) {\n var col = right - j, row = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;\n if (!fixed[row][col]) { m[row][col] = index < stream.length ? stream[index] === 1 : false; index++; }\n }\n }\n }\n var MASKS = [\n function (r, c) { return (r + c) % 2 === 0; }, function (r) { return r % 2 === 0; },\n function (r, c) { return c % 3 === 0; }, function (r, c) { return (r + c) % 3 === 0; },\n function (r, c) { return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0; },\n function (r, c) { return (r * c) % 2 + (r * c) % 3 === 0; },\n function (r, c) { return ((r * c) % 2 + (r * c) % 3) % 2 === 0; },\n function (r, c) { return ((r + c) % 2 + (r * c) % 3) % 2 === 0; }];\n function finish(mask) {\n var q = m.map(function (row, r) { return row.map(function (dark, c) { return fixed[r][c] ? dark : dark !== MASKS[mask](r, c); }); });\n var f = formatBits(mask);\n for (var k = 0; k < 15; k++) {\n var on = ((f >>> k) & 1) === 1;\n // First copy around the top-left finder (bits 0-7 down column 8, skipping the timing row; 8-14 along row 8).\n if (k < 6) { q[k][8] = on; } else if (k < 8) { q[k + 1][8] = on; } else if (k === 8) { q[8][7] = on; } else { q[8][14 - k] = on; }\n // Second copy: bits 0-7 along row 8 from the right edge, bits 8-14 up column 8 from the bottom.\n if (k < 8) { q[8][size - 1 - k] = on; } else { q[size - 15 + k][8] = on; }\n }\n q[size - 8][8] = true;\n if (version >= 7) {\n var v = versionBits(version);\n for (k = 0; k < 18; k++) {\n var bit = ((v >>> k) & 1) === 1;\n q[Math.floor(k / 3)][size - 11 + k % 3] = bit; q[size - 11 + k % 3][Math.floor(k / 3)] = bit;\n }\n }\n return q;\n }\n function penalty(q) {\n var score = 0, dark = 0, n = q.length;\n function line(get) {\n var s = 0;\n for (var a = 0; a < n; a++) {\n var run = 1, seq = [];\n for (var b = 0; b < n; b++) {\n seq.push(get(a, b) ? 1 : 0);\n if (b > 0) { if (get(a, b) === get(a, b - 1)) { run++; } else { if (run >= 5) { s += run - 2; } run = 1; } }\n }\n if (run >= 5) { s += run - 2; }\n var str = seq.join('');\n ['10111010000', '00001011101'].forEach(function (p) { for (var at = str.indexOf(p); at >= 0; at = str.indexOf(p, at + 1)) { s += 40; } });\n }\n return s;\n }\n score += line(function (a, b) { return q[a][b]; }) + line(function (a, b) { return q[b][a]; });\n for (var r = 0; r < n; r++) {\n for (var c = 0; c < n; c++) {\n if (q[r][c]) { dark++; }\n if (r < n - 1 && c < n - 1 && q[r][c] === q[r][c + 1] && q[r][c] === q[r + 1][c] && q[r][c] === q[r + 1][c + 1]) { score += 3; }\n }\n }\n return score + Math.floor(Math.abs(dark * 100 / (n * n) - 50) / 5) * 10;\n }\n var best = null, bestScore = Infinity;\n for (var mask = 0; mask < 8; mask++) {\n var candidate = finish(mask), s = penalty(candidate);\n if (s < bestScore) { best = candidate; bestScore = s; }\n }\n return best;\n }\n encode.parts = Object.freeze({ rs: rs, formatBits: formatBits, versionBits: versionBits, codewords: codewords });\n return Object.freeze(encode);\n })();\n // QR-ENCODER-END\n }\n function monotonic() { return typeof performance !== 'undefined' ? performance.now() : NaN; }\n function absolute(value) {\n if (typeof value !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/.test(value)) { return NaN; }\n var ms = Date.parse(value);\n return isFinite(ms) && new Date(ms).toISOString().slice(0,19) === value.slice(0,19) ? ms : NaN;\n }\n function clearPayment() {\n if (timer !== null) { clearInterval(timer); timer = null; }\n current = null;\n el('paymentQR').textContent = ''; el('paymentQR').hidden = true;\n el('paymentExpiry').textContent = '';\n el('wallet').hidden = true;\n el('copyAddr').disabled = true; el('copyAmount').disabled = true;\n }\n function paymentInfo(o) {\n if (!o || typeof o.crypto !== 'string' || !/^[A-Z0-9]{2,12}$/.test(o.crypto) ||\n typeof o.address !== 'string' || !/^[A-Za-z0-9]{6,128}$/.test(o.address) ||\n typeof o.crypto_amount !== 'string' || o.crypto_amount.length > 128) { return null; }\n var digits = o.crypto === 'BTC' ? 8 : o.crypto === 'XMR' ? 12 : 30;\n if (!new RegExp('^(0|[1-9][0-9]*)(\\\\.[0-9]{1,' + digits + '})?$').test(o.crypto_amount) || !/[1-9]/.test(o.crypto_amount)) { return null; }\n var uri = null;\n if (o.crypto === 'BTC' || o.crypto === 'XMR') {\n if (o.network != null && (typeof o.network !== 'string' || !(o.crypto === 'BTC' ? /^(Bitcoin|BTC|mainnet)$/i : /^(Monero|XMR|mainnet)$/i).test(o.network))) { return null; }\n var expected = (o.crypto === 'BTC' ? 'bitcoin:' : 'monero:') + o.address +\n (o.crypto === 'BTC' ? '?amount=' : '?tx_amount=') + o.crypto_amount;\n if (o.address.length < 14 || o.payment_uri !== expected) { return null; }\n uri = expected;\n }\n var expiry = absolute(o.expires_at), now = monotonic(), deadline = null;\n if (o.expiry_state === 'absolute' && isFinite(serverTime) && isFinite(expiry) && isFinite(now)) {\n deadline = now + expiry - serverTime;\n var key = JSON.stringify([invoiceId,o.crypto,o.network,o.address,o.crypto_amount,o.expires_at]);\n // Replayed identical proposals cannot restart their old payment window.\n if (deadlines.has(key)) { deadline = Math.min(deadline, deadlines.get(key)); }\n if (!deadlines.has(key) && deadlines.size >= 64) { deadline = null; }\n else { deadlines.set(key, deadline); }\n }\n return { option:o, uri:uri, deadline:deadline };\n }\n function activePayment() {\n if (current && current.deadline !== null && current.deadline <= monotonic()) { expirePayment(); }\n return current;\n }\n function expirePayment() {\n clearPayment(); el('pay').hidden = true; el('verify').hidden = true;\n el('paymentExpiry').textContent = 'Payment window expired. Check status or request fresh details before sending.';\n Bridge.reportSize();\n }\n function drawQr(info) {\n if (!paymentEncoder || !info.uri || info.deadline === null) { return; }\n try {\n var matrix = paymentEncoder(info.uri), n = matrix.length;\n if (!Array.isArray(matrix) || n < 21 || n > 177 || n % 4 !== 1 || !Array.from(matrix).every(function(row) {\n return Array.isArray(row) && row.length === n && Array.from(row).every(function(cell) { return typeof cell === 'boolean'; });\n })) { return; }\n var canvas = document.createElement('canvas'), scale = 4;\n canvas.width = (n+8)*scale; canvas.height = (n+8)*scale;\n canvas.setAttribute('aria-hidden','true');\n var pen = canvas.getContext('2d'); if (!pen) { return; }\n pen.fillStyle = '#fff'; pen.fillRect(0,0,canvas.width,canvas.height);\n pen.fillStyle = '#000';\n matrix.forEach(function(row,y){row.forEach(function(on,x){if(on){pen.fillRect((x+4)*scale,(y+4)*scale,scale,scale);}});});\n el('paymentQR').appendChild(canvas); el('paymentQR').hidden = false;\n el('qrAvailability').hidden = true;\n } catch (e) { /* A missing encoder never substitutes a pretend QR or upstream error. */ }\n }\n function bindPayment(o) {\n clearPayment(); el('qrAvailability').hidden = false;\n current = paymentInfo(o);\n if (!current) {\n el('pay').hidden = true; el('verify').hidden = true;\n el('paymentExpiry').textContent = 'Payment details cannot be verified. Request fresh details before sending.';\n return;\n }\n el('copyAddr').disabled = false; el('copyAmount').disabled = false;\n if (current.deadline === null) {\n el('paymentExpiry').textContent = 'Payment expiry is unknown. No countdown is available; request fresh details before sending.';\n return;\n }\n function tick() {\n var info = activePayment(); if (!info) { return; }\n var seconds = Math.ceil((info.deadline-monotonic())/1000);\n el('paymentExpiry').textContent = 'Payment window: '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0')+' remaining.';\n }\n tick(); if (!current) { return; }\n drawQr(current); timer = setInterval(tick,1000);\n el('wallet').hidden = !(current.uri && Bridge.canOpenLinks());\n }\n\n function setChip(state, text) {\n var chip = el('chip');\n chip.dataset.state = state;\n chip.textContent = text;\n }\n\n function money(amount, currency) {\n var n = Number(amount);\n var shown = isFinite(n) ? n.toFixed(2) : String(amount == null ? '' : amount);\n return shown + ' ' + String(currency || '');\n }\n\n // Groups of four. The FIRST four and the LAST four characters are the anchors\n // a person actually checks against their wallet, so they are taken as the\n // edges before the middle is chunked \u2014 grouping left-to-right and marking the\n // final group would leave a two-character anchor on many addresses, which is\n // a weaker thing to ask someone to verify.\n function renderAddress(address) {\n var box = el('addr');\n box.textContent = '';\n var s = String(address || '');\n if (s === '') { return; }\n\n var parts;\n if (s.length <= 12) {\n parts = [{ t: s, edge: true }];\n } else {\n parts = [{ t: s.slice(0, 4), edge: true }];\n (s.slice(4, -4).match(/.{1,4}/g) || []).forEach(function (g) {\n parts.push({ t: g, edge: false });\n });\n parts.push({ t: s.slice(-4), edge: true });\n }\n\n parts.forEach(function (p) {\n var span = document.createElement('span');\n span.className = 'g' + (p.edge ? ' edge' : '');\n span.textContent = p.t;\n box.appendChild(span);\n });\n }\n\n function renderRails() {\n var box = el('rails');\n box.textContent = '';\n if (options.length < 2) { box.hidden = true; return; }\n box.hidden = false;\n options.forEach(function (o, i) {\n var b = document.createElement('button');\n b.type = 'button';\n b.className = 'rail';\n b.setAttribute('role', 'tab');\n b.setAttribute('aria-selected', i === picked ? 'true' : 'false');\n b.textContent = String(o.crypto || o.rail || '?');\n b.addEventListener('click', function () { generation++; busy = false; el('check').disabled = false; picked = i; renderOption(); renderRails(); });\n box.appendChild(b);\n });\n }\n\n function renderOption() {\n clearPayment();\n var o = options[picked];\n if (!o) { el('pay').hidden = true; el('verify').hidden = true; return; }\n el('pay').hidden = false;\n el('verify').hidden = false;\n\n el('amount').textContent = String(o.crypto_amount || '') + ' ' + String(o.crypto || '');\n el('addrLabel').textContent = o.network\n ? 'To this address \u00B7 ' + String(o.network)\n : 'To this address';\n renderAddress(o.address);\n\n var rate = el('rate');\n var bits = [];\n if (o.rate) { bits.push('Rate ' + String(o.rate)); }\n\n if (o.note) { bits.push(String(o.note)); }\n rate.textContent = bits.join(' \u00B7 ');\n rate.hidden = bits.length === 0;\n\n // A wallet hand-off is only offered when the host says it can open links.\n bindPayment(o);\n Bridge.reportSize();\n }\n\n function renderRest(available) {\n var box = el('rest');\n if (!available) { box.hidden = true; return; }\n // Whatever is already a tab is not \"also payable in\" \u2014 repeating it reads\n // as a second, worse route to the same thing.\n var shown = {};\n options.forEach(function (o) { shown[String(o.crypto || '').toUpperCase()] = true; });\n var more = [];\n var add = function (c) {\n var k = String(c).toUpperCase();\n if (!shown[k]) { shown[k] = true; more.push(k); }\n };\n (available.tronpay || []).forEach(add);\n (available.altcoins_via_fixedfloat || []).forEach(add);\n if (more.length === 0) { box.hidden = true; return; }\n box.hidden = false;\n box.textContent = 'Also payable in ' + more.slice(0, 12).join(', ')\n + (more.length > 12 ? ' and more' : '')\n + ' \u2014 ask for that coin to get its own address.';\n }\n\n function render(data) {\n generation++; busy = false; el('check').disabled = false; clearPayment();\n if (!data || typeof data !== 'object' || !Number.isSafeInteger(data.invoice_id) || data.invoice_id <= 0) { fail(); return; }\n // Only an explicit pending proposal may offer payment actions. Cancelled,\n // refunded, absent and unknown states never inherit an old payable card.\n if (data.status !== 'pending' && data.status !== 'paid') { fail(); return; }\n invoiceId = data.invoice_id; serverTime = absolute(data.server_time);\n el('status').textContent = ''; el('status').className = 'note'; el('qrAvailability').hidden = false;\n\n el('total').textContent = money(data.fiat_amount, data.currency);\n el('sub').textContent = invoiceId != null ? ('Top-up invoice #' + String(invoiceId)) : '';\n\n var paid = String(data.status || '').toLowerCase() === 'paid';\n setChip(paid ? 'paid' : 'pending', paid ? 'Paid' : 'Awaiting payment');\n\n options = Array.isArray(data.payment_options) ? data.payment_options : [];\n if (picked >= options.length) { picked = 0; }\n\n if (paid) {\n el('qrAvailability').hidden = true;\n el('pay').hidden = true;\n el('rails').hidden = true;\n el('verify').hidden = true;\n el('rest').hidden = true;\n el('wallet').hidden = true;\n el('status').textContent = data.balance != null\n ? ('Credited. Account balance is now ' + money(data.balance, data.currency) + '.')\n : 'Credited to the account balance.';\n } else {\n renderRails();\n renderOption();\n renderRest(data.available);\n }\n\n el('check').hidden = paid || invoiceId == null;\n Bridge.reportSize();\n }\n\n function fail(text) {\n generation++; busy = false; invoiceId = null; options = []; clearPayment();\n el('pay').hidden = true; el('rails').hidden = true; el('check').hidden = true; el('verify').hidden = true; el('rest').hidden = true;\n el('addr').textContent = ''; el('amount').textContent = '';\n setChip('error', 'Unavailable');\n el('sub').textContent = '';\n var s = el('status');\n s.className = 'note warn';\n s.textContent = 'Payment details unavailable. Request fresh details.';\n Bridge.reportSize();\n }\n\n el('copyAmount').addEventListener('click', function () {\n var o = options[picked];\n if (o && activePayment()) { Bridge.copy(o.crypto_amount, this); }\n });\n el('copyAddr').addEventListener('click', function () {\n var o = options[picked];\n if (o && activePayment()) { Bridge.copy(o.address, this); }\n });\n\n el('wallet').addEventListener('click', function () {\n var o = options[picked];\n var info = activePayment();\n if (!o || !info || !info.uri || info.deadline === null) { return; }\n var expected = generation;\n Bridge.openLink(info.uri).catch(function () {\n if (expected !== generation) { return; }\n el('status').textContent = 'The host declined to open the wallet link \u2014 copy the address instead.';\n });\n });\n\n // The one server call this panel makes, and the mildest one available: a\n // read-scope status poll on the caller's own invoice.\n el('check').addEventListener('click', function () {\n if (busy || invoiceId == null) { return; }\n busy = true;\n var expected = generation, checkedInvoice = invoiceId;\n var btn = this;\n btn.disabled = true;\n el('status').className = 'note';\n el('status').textContent = 'Checking\u2026';\n Bridge.callTool('impreza_topup_status', { invoice_id: invoiceId })\n .then(function (data) {\n if (expected !== generation || checkedInvoice !== invoiceId) { return; }\n if (data && String(data.status || '').toLowerCase() === 'paid') {\n render({\n invoice_id: invoiceId,\n fiat_amount: data.amount != null ? data.amount : data.fiat_amount,\n currency: data.currency,\n status: 'paid',\n balance: data.balance\n });\n } else if (data && data.status === 'pending') {\n el('status').textContent = 'Not confirmed yet. On-chain confirmation usually takes a few minutes.';\n } else {\n // A cancelled/refunded/unknown result cannot leave payment actions\n // from the previous pending snapshot available.\n fail();\n }\n })\n .catch(function (e) {\n if (expected !== generation) { return; }\n el('status').className = 'note warn';\n el('status').textContent = 'The check failed. Try again in a moment.';\n })\n .then(function () {\n if (expected !== generation) { return; }\n busy = false;\n btn.disabled = false;\n Bridge.reportSize();\n });\n });\n\n Bridge.start({ name: 'impreza-topup-card', onResult: render, onFail: fail });\n})();\n</script>\n</body>\n</html>\n";
|
|
2
2
|
/** md5 of the canonical lib/ui/topup-card.html, LF-normalised. */
|
|
3
|
-
export declare const TOPUP_CARD_HTML_MD5 = "
|
|
4
|
-
export declare const SERVER_CARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Server</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_vps_status.\n\n The control panel that needs no clientarea login: what the machine is, how\n hard it is working, and the power controls, in the chat where the customer\n already is.\n\n Decisions worth knowing:\n\n \u00B7 The card is seeded from `structuredContent` (the live numbers, immediately)\n plus `ui/notifications/tool-input` (which carries the service_id the tool\n was called with \u2014 the status result itself does not contain it). It then\n makes ONE enrichment call for the hardware identity, which never changes.\n Deployments load only when asked: most people opening a server card want to\n know whether it is up, not to enumerate apps.\n\n \u00B7 Reboot and shutdown ARM on the first click and fire on the second. The host\n adds its own consent gate for a tool call from a view, but a mis-click that\n reboots a production VM costs someone an afternoon, and the panel is the\n right place to make intent explicit. Start fires directly: it can only help.\n\n This panel deliberately calls no confirmation-gated tool. Those exist because\n intent must be proven across two SEPARATE calls, and a view that did both\n halves on one click would defeat the gate rather than satisfy it.\n\n F58.10 \u2014 observation, not new control:\n\n \u00B7 A number the server did not send is shown as \"not reported\", never as 0.\n `cpu_usage` is the hypervisor's 0\u20131 fraction of all vCPUs\n (ProxmoxVPSController::status); anything outside 0\u20131 is shown as reported\n with \"unit unclear\", without a bar, rather than guessed into a percentage.\n Disk and network are cumulative counters since the VM started, labelled as\n such: the status call carries no disk usage, only the size from identity.\n\n \u00B7 The status payload has no timestamp, so freshness is the time THIS view\n received it. Past two minutes the readings are marked out of date; a failed\n refresh keeps the last reading, marked as such, instead of clearing it.\n\n \u00B7 A different service_id resets everything: no reading, identity or app list\n of the previous server survives the switch, and an answer still in flight\n for it is dropped when it lands.\n\n \u00B7 Logs and progress have no read contract at the VPS level (the log tools are\n per deployment, and impreza_vps_power returns no operation to follow), so\n the card says so instead of showing anything that looks like them.\n\n \u00B7 Receiving a payload never calls a tool that changes anything: only the\n identity read on a new service_id, and the app list when asked.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* \u2500\u2500 identity strip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .facts {\n display: flex;\n flex-wrap: wrap;\n gap: 4px 16px;\n color: var(--color-text-secondary);\n font-size: 12px;\n }\n .facts b {\n color: var(--color-text-primary);\n font-family: var(--font-mono);\n font-weight: 600;\n overflow-wrap: anywhere;\n }\n\n /* \u2500\u2500 meters: real scales, and every label names a value they reach \u2500\u2500\u2500\u2500\u2500\u2500 */\n .meters { display: flex; flex-direction: column; gap: 10px; }\n .meter-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 4px;\n }\n .meter-val {\n font-family: var(--font-mono);\n font-size: 12px;\n font-variant-numeric: tabular-nums;\n color: var(--color-text-secondary);\n text-align: right;\n overflow-wrap: anywhere;\n }\n .meter-val[data-quality=\"bad\"] { color: var(--color-text-warning); }\n .track {\n height: 6px;\n border-radius: var(--border-radius-full);\n background: var(--color-background-tertiary);\n overflow: hidden;\n }\n .fill {\n height: 100%;\n width: 0;\n border-radius: var(--border-radius-full);\n background: var(--color-text-secondary);\n }\n /* Pressure, not decoration: the bar changes colour only where it matters. */\n .fill[data-load=\"high\"] { background: var(--color-text-warning); }\n .fill[data-load=\"full\"] { background: var(--color-text-danger); }\n\n /* \u2500\u2500 counters since boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .counters {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 2px 12px;\n font-size: 12px;\n color: var(--color-text-secondary);\n }\n .counters dt { color: var(--color-text-tertiary); }\n .counters dd {\n margin: 0;\n font-family: var(--font-mono);\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n }\n\n /* An old reading stays readable, but no longer looks live. */\n .card[data-stale=\"1\"] .meters,\n .card[data-stale=\"1\"] .counters { opacity: 0.55; }\n .fresh[data-stale=\"1\"] { color: var(--color-text-warning); }\n\n /* \u2500\u2500 deployments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .apps { display: flex; flex-direction: column; gap: 6px; }\n .app {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 10px;\n padding: 7px 10px;\n border: 1px solid var(--color-border-secondary);\n border-radius: var(--border-radius-sm);\n font-size: 12.5px;\n }\n .app-name { font-family: var(--font-mono); font-weight: 600; overflow-wrap: anywhere; }\n .app-host { color: var(--color-text-secondary); overflow-wrap: anywhere; }\n .app-state { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }\n .app-state[data-state=\"ok\"] { color: var(--color-text-success); }\n .app-state[data-state=\"work\"] { color: var(--color-text-warning); }\n .app-state[data-state=\"bad\"] { color: var(--color-text-danger); }\n .app-state[data-state=\"gone\"] { color: var(--color-text-tertiary); }\n\n .section-head { margin-bottom: 4px; }\n\n /* Narrow chat columns and phones: one column, buttons share the row. */\n @media (max-width: 420px) {\n .facts { flex-direction: column; }\n .actions .btn { flex: 1 1 auto; }\n .app { flex-direction: column; align-items: flex-start; }\n }\n</style>\n</head>\n<body>\n<div class=\"card\" id=\"card\" data-stale=\"0\">\n <div class=\"head\">\n <div>\n <div class=\"title\" id=\"host\">\u2014</div>\n <div class=\"sub\" id=\"sub\">Loading server details\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"idle\">Loading</div>\n </div>\n\n <div class=\"facts\" id=\"facts\" hidden></div>\n\n <div class=\"meters\" id=\"meters\" hidden>\n <div>\n <div class=\"meter-head\">\n <span class=\"label\" id=\"cpuLabel\">CPU</span>\n <span class=\"meter-val\" id=\"cpuVal\"></span>\n </div>\n <div class=\"track\" id=\"cpuTrack\" role=\"meter\" aria-labelledby=\"cpuLabel\" aria-valuemin=\"0\" aria-valuemax=\"100\"><div class=\"fill\" id=\"cpuFill\"></div></div>\n </div>\n <div>\n <div class=\"meter-head\">\n <span class=\"label\" id=\"memLabel\">Memory</span>\n <span class=\"meter-val\" id=\"memVal\"></span>\n </div>\n <div class=\"track\" id=\"memTrack\" role=\"meter\" aria-labelledby=\"memLabel\" aria-valuemin=\"0\" aria-valuemax=\"100\"><div class=\"fill\" id=\"memFill\"></div></div>\n </div>\n </div>\n\n <dl class=\"counters\" id=\"counters\" hidden>\n <dt>Uptime</dt><dd id=\"upVal\"></dd>\n <dt>Disk I/O since start</dt><dd id=\"diskVal\"></dd>\n <dt>Network since start</dt><dd id=\"netVal\"></dd>\n </dl>\n\n <div class=\"note fresh\" id=\"fresh\" data-stale=\"0\"></div>\n\n <div class=\"actions\">\n <button class=\"btn\" id=\"refresh\" type=\"button\">Refresh</button>\n <button class=\"btn\" id=\"start\" type=\"button\" hidden>Start</button>\n <button class=\"btn\" id=\"reboot\" type=\"button\" hidden>Reboot</button>\n <button class=\"btn\" id=\"shutdown\" type=\"button\" hidden>Shut down</button>\n <span class=\"note\" id=\"status\" role=\"status\" aria-live=\"polite\"></span>\n </div>\n\n <div>\n <div class=\"label section-head\">Activity</div>\n <div class=\"note\" id=\"activity\">Logs and progress for this server are not available in this card yet. Power actions report only that they were accepted; refresh to see the new state.</div>\n </div>\n\n <div>\n <button class=\"btn quiet\" id=\"appsToggle\" type=\"button\" aria-expanded=\"false\" aria-controls=\"apps\">Show deployed apps</button>\n <div class=\"apps\" id=\"apps\" hidden style=\"margin-top:10px\"></div>\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n // Past this, the reading is labelled out of date. Measured from when this\n // view received it: the payload carries no time of its own.\n var STALE_MS = 120000;\n var MAX_IPS = 8;\n var MAX_APPS = 50;\n\n var serviceId = null;\n var generation = 0; // bumped on every service switch; late answers for an older one are dropped\n var powerState = '';\n var busy = false;\n var appsLoaded = false;\n var receivedAt = 0; // ms; 0 = no reading for this service yet\n var refreshFailed = false; // the last refresh failed: the reading shown is the old one\n var appTimers = [];\n\n // \u2500\u2500 input hygiene \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Everything below comes from a tool result: treat it as hostile text.\n function clip(value, max) {\n var s = String(value);\n return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;\n }\n\n // A finite number, or null. null/''/booleans/objects are \"not reported\", never 0.\n function num(value) {\n if (typeof value === 'number') { return isFinite(value) ? value : null; }\n if (typeof value === 'string' && /^\\s*-?\\d+(\\.\\d+)?\\s*$/.test(value)) { return Number(value); }\n return null;\n }\n\n function serviceIdOf(args) {\n if (!args || typeof args !== 'object') { return null; }\n // MCP Apps sends the tool arguments under `arguments`; the Impreza Panel\n // shell hands them over directly. Accept both, nothing else.\n var src = (args.arguments && typeof args.arguments === 'object') ? args.arguments : args;\n var raw = src.service_id;\n if (typeof raw === 'number' && raw > 0 && raw % 1 === 0 && raw <= 4294967295) { return raw; }\n if (typeof raw === 'string' && /^[1-9]\\d{0,9}$/.test(raw) && Number(raw) <= 4294967295) { return Number(raw); }\n return null;\n }\n\n // \u2500\u2500 formatting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function gib(bytes) {\n var n = num(bytes);\n if (n === null || n <= 0) { return null; }\n return n / 1073741824;\n }\n\n function size(bytes) {\n var n = num(bytes);\n if (n === null || n < 0) { return null; }\n var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];\n var i = 0;\n while (n >= 1024 && i < units.length - 1) { n = n / 1024; i++; }\n return (i === 0 ? String(Math.round(n)) : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + units[i];\n }\n\n function uptime(seconds) {\n var s = num(seconds);\n if (s === null || s <= 0) { return null; }\n var d = Math.floor(s / 86400);\n var h = Math.floor((s % 86400) / 3600);\n var m = Math.floor((s % 3600) / 60);\n if (d > 0) { return d + 'd ' + h + 'h'; }\n if (h > 0) { return h + 'h ' + m + 'm'; }\n return m + 'm';\n }\n\n function clock(ms) {\n return new Date(ms).toISOString().slice(11, 16) + ' UTC';\n }\n\n function load(pct) {\n if (pct >= 90) { return 'full'; }\n if (pct >= 70) { return 'high'; }\n return 'normal';\n }\n\n // `pct` null draws no bar at all: an empty track would read as 0%.\n // `neutral` keeps the bar in the default colour whatever the number says.\n // Used for the memory reading, which comes from the hypervisor and counts\n // page cache as used: 80% is what a healthy machine looks like there, so the\n // 70%/90% thresholds do not describe anything real and an amber bar would be\n // an alarm with nothing behind it.\n function setMeter(prefix, pct, text, neutral, bad) {\n var fill = el(prefix + 'Fill');\n var track = el(prefix + 'Track');\n var val = el(prefix + 'Val');\n val.textContent = text;\n val.dataset.quality = bad ? 'bad' : 'ok';\n track.setAttribute('aria-valuetext', text);\n if (pct === null) {\n fill.style.width = '0%';\n fill.dataset.load = 'none';\n track.removeAttribute('aria-valuenow');\n return;\n }\n var p = Math.max(0, Math.min(100, pct));\n fill.style.width = p.toFixed(1) + '%';\n fill.dataset.load = neutral ? 'normal' : load(p);\n track.setAttribute('aria-valuenow', p.toFixed(1));\n }\n\n function cpuReading(raw) {\n var n = num(raw);\n if (n === null) { return { pct: null, text: 'not reported' }; }\n if (n < 0 || n > 1) {\n return { pct: null, text: clip(n, 24) + ' as reported (unit unclear, expected 0\u20131)', bad: true };\n }\n var pct = n * 100;\n return { pct: pct, text: pct.toFixed(1) + '% of all vCPUs' };\n }\n\n function memReading(d) {\n var used = gib(d.memory_used);\n var total = gib(d.memory_total);\n // The hosted server sends a `memory` block that says what its own number\n // means. Prefer it; fall back to dividing the raw bytes for a payload that\n // predates it (the local npm server, an older API).\n var info = (d.memory && typeof d.memory === 'object') ? d.memory : null;\n var cached = !!(info && info.includes_cache_and_buffers === true);\n var pct = info ? num(info.percent) : null;\n if (pct !== null && (pct < 0 || pct > 100)) { pct = null; }\n if (pct === null && used !== null && total !== null) { pct = (used / total) * 100; }\n if (used === null || total === null) {\n return pct === null ? { pct: null, text: 'not reported' }\n : { pct: pct, text: pct.toFixed(0) + '%' + (cached ? ' incl. cache' : ''), cached: cached };\n }\n if (used > total * 1.001) {\n return { pct: null, text: used.toFixed(2) + ' GiB used of ' + total.toFixed(2) + ' GiB (inconsistent)', bad: true };\n }\n // \"incl. cache\" is four words that stop the reader concluding the machine\n // is about to run out. Without it this card showed 80% on a box the\n // in-guest agent reports at 20%, and a model reading the same number told\n // a customer to go check swap.\n return {\n pct: pct,\n text: used.toFixed(2) + ' of ' + total.toFixed(2) + ' GiB \u00B7 ' + pct.toFixed(0) + '%' + (cached ? ' incl. cache' : ''),\n cached: cached\n };\n }\n\n function pairOf(block, a, b, aLabel, bLabel) {\n if (!block || typeof block !== 'object') { return 'not reported'; }\n var x = size(block[a]);\n var y = size(block[b]);\n if (x === null && y === null) { return 'not reported'; }\n return (x === null ? 'not reported' : x) + ' ' + aLabel + ' \u00B7 ' + (y === null ? 'not reported' : y) + ' ' + bLabel;\n }\n\n // \u2500\u2500 freshness \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function updateFreshness() {\n var f = el('fresh');\n if (!receivedAt) {\n f.textContent = '';\n f.dataset.stale = '0';\n el('card').dataset.stale = '0';\n return;\n }\n var age = Math.max(0, Date.now() - receivedAt);\n var stale = age > STALE_MS || refreshFailed;\n var mins = Math.floor(age / 60000);\n var ago = mins < 1 ? 'just now' : (mins === 1 ? '1 min ago' : mins + ' min ago');\n f.textContent = stale\n ? 'Last reading ' + ago + ' (' + clock(receivedAt) + ') \u2014 may be out of date. Refresh to check.'\n : 'Updated ' + ago + ' (' + clock(receivedAt) + ').';\n f.dataset.stale = stale ? '1' : '0';\n el('card').dataset.stale = stale ? '1' : '0';\n }\n\n // \u2500\u2500 rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function setPower(state) {\n var s = (typeof state === 'string') ? clip(state.toLowerCase(), 32) : '';\n powerState = s === 'unknown' ? '' : s;\n var chip = el('chip');\n if (powerState === 'running') {\n chip.dataset.state = 'ok';\n chip.textContent = 'Running';\n } else if (powerState === 'stopped') {\n chip.dataset.state = 'off';\n chip.textContent = 'Stopped';\n } else if (powerState === '') {\n chip.dataset.state = 'idle';\n chip.textContent = 'Unknown';\n } else {\n chip.dataset.state = 'pending';\n chip.textContent = powerState;\n }\n\n // Offer only what the current state makes sense for.\n var running = powerState === 'running';\n el('start').hidden = running || powerState === '';\n el('reboot').hidden = !running;\n el('shutdown').hidden = !running;\n disarmAll();\n }\n\n function renderStatus(d) {\n if (!d || typeof d !== 'object' || Array.isArray(d)) {\n fail('The server returned nothing this card can show.');\n return;\n }\n setPower(d.power_state);\n\n var cpu = cpuReading(d.cpu_usage);\n var mem = memReading(d);\n el('meters').hidden = false;\n setMeter('cpu', cpu.pct, cpu.text, false, cpu.bad);\n setMeter('mem', mem.pct, mem.text, mem.cached, mem.bad);\n\n var up = uptime(d.uptime);\n el('upVal').textContent = up || 'not reported';\n el('diskVal').textContent = pairOf(d.disk_io, 'read_bytes', 'write_bytes', 'read', 'written');\n el('netVal').textContent = pairOf(d.network, 'in_bytes', 'out_bytes', 'in', 'out');\n el('counters').hidden = false;\n\n el('sub').textContent = serviceId !== null ? ('Service #' + serviceId) : '';\n receivedAt = Date.now();\n refreshFailed = false;\n updateFreshness();\n Bridge.reportSize();\n }\n\n function renderIdentity(info) {\n if (!info || typeof info !== 'object' || Array.isArray(info)) { return; }\n if (typeof info.hostname === 'string' && info.hostname !== '') { el('host').textContent = clip(info.hostname, 253); }\n\n var res = (info.resources && typeof info.resources === 'object') ? info.resources : {};\n var bits = [];\n var push = function (label, value) {\n if (value === null || value === undefined || value === '') { return; }\n bits.push([label, String(value)]);\n };\n push('Node', typeof info.node === 'string' ? clip(info.node, 64) : null);\n var cores = num(res.cores);\n push('vCPU', cores !== null && cores > 0 ? cores : null);\n var memMb = num(res.memory_mb);\n push('RAM', memMb !== null && memMb > 0 ? (Math.round(memMb / 1024 * 10) / 10) + ' GiB' : null);\n var diskGb = num(res.disk_gb);\n push('Disk', diskGb !== null && diskGb > 0 ? diskGb + ' GB (size)' : null);\n var ips = Array.isArray(info.ips)\n ? info.ips.map(function (i) { return (i && typeof i.ip === 'string') ? clip(i.ip, 45) : ''; }).filter(Boolean)\n : [];\n if (ips.length > MAX_IPS) { ips = ips.slice(0, MAX_IPS).concat(['+' + (ips.length - MAX_IPS) + ' more']); }\n push('IP', ips.join(', '));\n if (typeof info.os === 'string') { push('OS', clip(info.os, 120)); }\n\n var box = el('facts');\n box.textContent = '';\n bits.forEach(function (pair) {\n var span = document.createElement('span');\n span.appendChild(document.createTextNode(pair[0] + ' '));\n var b = document.createElement('b');\n b.textContent = pair[1];\n span.appendChild(b);\n box.appendChild(span);\n });\n box.hidden = bits.length === 0;\n Bridge.reportSize();\n }\n\n function appState(status) {\n var s = String(status || '').toLowerCase();\n if (s === 'running' || s === 'healthy') { return ['ok', s]; }\n if (s === 'failed' || s === 'degraded') { return ['bad', s]; }\n if (s === 'uninstalled') { return ['gone', s]; }\n return ['work', clip(s || 'unknown', 32)];\n }\n\n function clearAppTimers() {\n appTimers.forEach(function (t) { clearTimeout(t); });\n appTimers = [];\n }\n\n function renderApps(list) {\n var box = el('apps');\n box.textContent = '';\n clearAppTimers();\n var rows = Array.isArray(list) ? list.filter(function (d) { return d && typeof d === 'object'; }) : [];\n if (rows.length === 0) {\n var none = document.createElement('div');\n none.className = 'note';\n none.textContent = 'No apps deployed on this server yet.';\n box.appendChild(none);\n Bridge.reportSize();\n return;\n }\n rows.slice(0, MAX_APPS).forEach(function (d) {\n var row = document.createElement('div');\n row.className = 'app';\n\n var left = document.createElement('div');\n var name = document.createElement('div');\n name.className = 'app-name';\n name.textContent = clip(d.app_name || d.name || d.id || '?', 120);\n left.appendChild(name);\n if (typeof d.domain === 'string' && d.domain !== '') {\n var host = document.createElement('div');\n host.className = 'app-host';\n host.textContent = clip(d.domain.replace(/^https?:\\/\\//, ''), 253);\n left.appendChild(host);\n }\n row.appendChild(left);\n\n var runtime = (d.runtime && typeof d.runtime === 'object') ? d.runtime : {};\n var pair = appState(runtime.state || 'unknown');\n var st = document.createElement('div');\n st.className = 'app-state';\n st.dataset.state = pair[0];\n st.textContent = 'Runtime: ' + pair[1];\n st.title = 'Observed: ' + clip(runtime.observed_at || 'not reported', 40) + '. Last operation: '\n + clip(((d.last_operation || {}).status || d.status || 'unknown'), 32) + '. Containers only.';\n if (runtime.state && runtime.state !== 'unknown') {\n var age = num(runtime.age_seconds);\n appTimers.push(setTimeout(function () { st.textContent = 'Runtime: unknown'; st.dataset.state = 'work'; },\n Math.max(0, 120 - (age === null ? 0 : age)) * 1000));\n }\n row.appendChild(st);\n\n box.appendChild(row);\n });\n if (rows.length > MAX_APPS) {\n var more = document.createElement('div');\n more.className = 'note';\n more.textContent = (rows.length - MAX_APPS) + ' more not shown.';\n box.appendChild(more);\n }\n Bridge.reportSize();\n }\n\n function clearReadings() {\n el('meters').hidden = true;\n el('counters').hidden = true;\n ['cpu', 'mem'].forEach(function (p) { setMeter(p, null, '', false, false); });\n ['upVal', 'diskVal', 'netVal'].forEach(function (id) { el(id).textContent = ''; });\n receivedAt = 0;\n refreshFailed = false;\n updateFreshness();\n }\n\n // A new service: nothing of the previous one may stay on screen.\n function resetView() {\n generation++;\n clearAppTimers();\n clearReadings();\n el('host').textContent = '\u2014';\n var facts = el('facts');\n facts.textContent = '';\n facts.hidden = true;\n var apps = el('apps');\n apps.textContent = '';\n apps.hidden = true;\n appsLoaded = false;\n var toggle = el('appsToggle');\n toggle.textContent = 'Show deployed apps';\n toggle.setAttribute('aria-expanded', 'false');\n setPower('');\n el('chip').textContent = 'Loading';\n say('');\n }\n\n function fail(text) {\n clearReadings();\n var chip = el('chip');\n chip.dataset.state = 'error';\n chip.textContent = 'Unavailable';\n el('sub').textContent = '';\n el('start').hidden = true;\n el('reboot').hidden = true;\n el('shutdown').hidden = true;\n say(clip(text || 'Could not load this server.', 300), true);\n }\n\n function say(text, bad) {\n var s = el('status');\n s.className = bad ? 'note warn' : 'note';\n s.textContent = text || '';\n Bridge.reportSize();\n }\n\n // \u2500\u2500 the arm-then-fire pattern for the disruptive actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function disarmAll() {\n ['reboot', 'shutdown'].forEach(function (id) {\n var b = el(id);\n b.dataset.armed = '0';\n b.textContent = b.dataset.base || b.textContent;\n });\n }\n\n function wirePower(id, action, disruptive) {\n var btn = el(id);\n btn.dataset.base = btn.textContent;\n btn.addEventListener('click', function () {\n if (busy || serviceId === null) { return; }\n if (disruptive && btn.dataset.armed !== '1') {\n disarmAll();\n btn.dataset.armed = '1';\n btn.textContent = 'Confirm ' + btn.dataset.base.toLowerCase();\n say('This interrupts everything running on the server. Click again to go ahead.');\n return;\n }\n disarmAll();\n busy = true;\n btn.disabled = true;\n var g = generation;\n say(btn.dataset.base + ' requested\u2026');\n Bridge.callTool('impreza_vps_power', { service_id: serviceId, action: action })\n .then(function () {\n if (g !== generation) { return; }\n say('Accepted. The state below refreshes as the server catches up.');\n setTimeout(function () { if (g === generation) { refreshStatus(); } }, 4000);\n })\n .catch(function (e) { if (g === generation) { say(clip(e.message || 'The host declined that action.', 300), true); } })\n .then(function () { busy = false; btn.disabled = false; Bridge.reportSize(); });\n });\n }\n\n // \u2500\u2500 server calls (reads only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function refreshStatus() {\n if (serviceId === null) { return Promise.resolve(); }\n var g = generation;\n return Bridge.callTool('impreza_vps_status', { service_id: serviceId })\n .then(function (d) { if (g === generation) { renderStatus(d); } })\n .catch(function (e) {\n if (g !== generation) { return; }\n // Keep the last reading: marked old, it is still the best we know.\n var msg = clip(e.message || 'Could not refresh.', 200);\n say(receivedAt ? msg + ' Showing the reading from ' + clock(receivedAt) + '.' : msg, true);\n if (receivedAt) { refreshFailed = true; updateFreshness(); }\n });\n }\n\n function loadIdentity() {\n if (serviceId === null) { return; }\n var g = generation;\n // One enrichment call, for the facts that do not change.\n Bridge.callTool('impreza_api_call', { path: '/vps/proxmox/' + serviceId })\n .then(function (info) { if (g === generation) { renderIdentity(info); } })\n .catch(function () { /* the card is still useful without the hardware strip */ });\n }\n\n function loadApps() {\n // Two calls, and only on demand: list_servers maps this service to its\n // agent, and only then can deployments be filtered to THIS machine.\n // Showing another server's apps would be worse than showing none.\n var g = generation;\n var sid = serviceId;\n return Bridge.callTool('impreza_list_servers', {})\n .then(function (d) {\n if (g !== generation) { return; }\n var servers = (d && Array.isArray(d.servers)) ? d.servers : [];\n var mine = servers.filter(function (s) { return s && String(s.service_id) === String(sid); })[0];\n if (!mine || !mine.agent_id) {\n renderApps([]);\n return;\n }\n return Bridge.callTool('impreza_list_deployments', { agent_id: mine.agent_id })\n .then(function (r) { if (g === generation) { renderApps((r && r.deployments) || []); } });\n })\n .catch(function (e) { if (g === generation) { say(clip(e.message || 'Could not list apps.', 300), true); } });\n }\n\n // \u2500\u2500 wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n el('refresh').addEventListener('click', function () {\n if (busy) { return; }\n say('Refreshing\u2026');\n var g = generation;\n refreshStatus().then(function () {\n if (g === generation && el('status').textContent === 'Refreshing\u2026') { say(''); }\n });\n });\n\n wirePower('start', 'start', false);\n wirePower('reboot', 'reboot', true);\n wirePower('shutdown', 'shutdown', true);\n\n el('appsToggle').addEventListener('click', function () {\n var box = el('apps');\n if (box.hidden) {\n box.hidden = false;\n this.textContent = 'Hide deployed apps';\n this.setAttribute('aria-expanded', 'true');\n if (!appsLoaded && serviceId !== null) { appsLoaded = true; loadApps(); }\n } else {\n box.hidden = true;\n this.textContent = 'Show deployed apps';\n this.setAttribute('aria-expanded', 'false');\n }\n Bridge.reportSize();\n });\n\n setInterval(updateFreshness, 15000);\n\n Bridge.start({\n name: 'impreza-server-card',\n onInput: function (args) {\n // The status result carries no service_id; the tool ARGUMENTS do, and\n // every later call this panel makes needs it.\n var sid = serviceIdOf(args);\n if (sid === null || sid === serviceId) { return; }\n if (serviceId !== null) { resetView(); }\n serviceId = sid;\n el('sub').textContent = 'Service #' + serviceId;\n loadIdentity();\n },\n onResult: renderStatus,\n onFail: fail\n });\n})();\n</script>\n</body>\n</html>\n";
|
|
3
|
+
export declare const TOPUP_CARD_HTML_MD5 = "da28ab3578d56e7b59d771787055d2d4";
|
|
4
|
+
export declare const SERVER_CARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Server</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_vps_status.\n\n The control panel that needs no clientarea login: what the machine is, how\n hard it is working, and the power controls, in the chat where the customer\n already is.\n\n Decisions worth knowing:\n\n \u00B7 The card is seeded from `structuredContent` (the live numbers, immediately)\n plus `ui/notifications/tool-input` (which carries the service_id the tool\n was called with \u2014 the status result itself does not contain it). It then\n makes ONE enrichment call for the hardware identity, which never changes.\n Deployments load only when asked: most people opening a server card want to\n know whether it is up, not to enumerate apps.\n\n \u00B7 Reboot and shutdown ARM on the first click and fire on the second. The host\n adds its own consent gate for a tool call from a view, but a mis-click that\n reboots a production VM costs someone an afternoon, and the panel is the\n right place to make intent explicit. Start fires directly: it can only help.\n\n This panel deliberately calls no confirmation-gated tool. Those exist because\n intent must be proven across two SEPARATE calls, and a view that did both\n halves on one click would defeat the gate rather than satisfy it.\n\n F58.10 \u2014 observation, not new control:\n\n \u00B7 A number the server did not send is shown as \"not reported\", never as 0.\n `cpu_usage` is the hypervisor's 0\u20131 fraction of all vCPUs\n (ProxmoxVPSController::status); anything outside 0\u20131 is shown as reported\n with \"unit unclear\", without a bar, rather than guessed into a percentage.\n Disk and network are cumulative counters since the VM started, labelled as\n such: the status call carries no disk usage, only the size from identity.\n\n \u00B7 The status payload has no timestamp, so freshness is the time THIS view\n received it. Past two minutes the readings are marked out of date; a failed\n refresh keeps the last reading, marked as such, instead of clearing it.\n\n \u00B7 A different service_id resets everything: no reading, identity or app list\n of the previous server survives the switch, and an answer still in flight\n for it is dropped when it lands.\n\n \u00B7 Logs and progress have no read contract at the VPS level (the log tools are\n per deployment, and impreza_vps_power returns no operation to follow), so\n the card says so instead of showing anything that looks like them.\n\n \u00B7 Receiving a payload never calls a tool that changes anything: only the\n identity read on a new service_id, and the app list when asked.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* \u2500\u2500 identity strip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .facts {\n display: flex;\n flex-wrap: wrap;\n gap: 4px 16px;\n color: var(--color-text-secondary);\n font-size: 12px;\n }\n .facts b {\n color: var(--color-text-primary);\n font-family: var(--font-mono);\n font-weight: 600;\n overflow-wrap: anywhere;\n }\n\n /* \u2500\u2500 meters: real scales, and every label names a value they reach \u2500\u2500\u2500\u2500\u2500\u2500 */\n .meters { display: flex; flex-direction: column; gap: 10px; }\n .meter-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 4px;\n }\n .meter-val {\n font-family: var(--font-mono);\n font-size: 12px;\n font-variant-numeric: tabular-nums;\n color: var(--color-text-secondary);\n text-align: right;\n overflow-wrap: anywhere;\n }\n .meter-val[data-quality=\"bad\"] { color: var(--color-text-warning); }\n .track {\n height: 6px;\n border-radius: var(--border-radius-full);\n background: var(--color-background-tertiary);\n overflow: hidden;\n }\n .fill {\n height: 100%;\n width: 0;\n border-radius: var(--border-radius-full);\n background: var(--color-text-secondary);\n }\n /* Pressure, not decoration: the bar changes colour only where it matters. */\n .fill[data-load=\"high\"] { background: var(--color-text-warning); }\n .fill[data-load=\"full\"] { background: var(--color-text-danger); }\n\n /* \u2500\u2500 counters since boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .counters {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 2px 12px;\n font-size: 12px;\n color: var(--color-text-secondary);\n }\n .counters dt { color: var(--color-text-tertiary); }\n .counters dd {\n margin: 0;\n font-family: var(--font-mono);\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n }\n\n /* An old reading stays readable, but no longer looks live. */\n .card[data-stale=\"1\"] .meters,\n .card[data-stale=\"1\"] .counters { opacity: 0.55; }\n .fresh[data-stale=\"1\"] { color: var(--color-text-warning); }\n\n /* \u2500\u2500 deployments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .apps { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }\n .app {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 10px;\n padding: 7px 10px;\n border: 1px solid var(--color-border-secondary);\n border-radius: var(--border-radius-sm);\n font-size: 12.5px;\n }\n .app-name { font-family: var(--font-mono); font-weight: 600; overflow-wrap: anywhere; }\n .app-host { color: var(--color-text-secondary); overflow-wrap: anywhere; }\n .app-state { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }\n .app-state[data-state=\"ok\"] { color: var(--color-text-success); }\n .app-state[data-state=\"work\"] { color: var(--color-text-warning); }\n .app-state[data-state=\"bad\"] { color: var(--color-text-danger); }\n .app-state[data-state=\"gone\"] { color: var(--color-text-tertiary); }\n\n .section-head { margin-bottom: 4px; }\n\n /* Narrow chat columns and phones: one column, buttons share the row. */\n @media (max-width: 420px) {\n .facts { flex-direction: column; }\n .actions .btn { flex: 1 1 auto; }\n .app { flex-direction: column; align-items: flex-start; }\n }\n</style>\n</head>\n<body>\n<div class=\"card\" id=\"card\" data-stale=\"0\">\n <div class=\"head\">\n <div>\n <div class=\"title\" id=\"host\">\u2014</div>\n <div class=\"sub\" id=\"sub\">Loading server details\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"idle\">Loading</div>\n </div>\n\n <div class=\"facts\" id=\"facts\" hidden></div>\n\n <div class=\"meters\" id=\"meters\" hidden>\n <div>\n <div class=\"meter-head\">\n <span class=\"label\" id=\"cpuLabel\">CPU</span>\n <span class=\"meter-val\" id=\"cpuVal\"></span>\n </div>\n <div class=\"track\" id=\"cpuTrack\" role=\"meter\" aria-labelledby=\"cpuLabel\" aria-valuemin=\"0\" aria-valuemax=\"100\"><div class=\"fill\" id=\"cpuFill\"></div></div>\n </div>\n <div>\n <div class=\"meter-head\">\n <span class=\"label\" id=\"memLabel\">Memory</span>\n <span class=\"meter-val\" id=\"memVal\"></span>\n </div>\n <div class=\"track\" id=\"memTrack\" role=\"meter\" aria-labelledby=\"memLabel\" aria-valuemin=\"0\" aria-valuemax=\"100\"><div class=\"fill\" id=\"memFill\"></div></div>\n </div>\n </div>\n\n <dl class=\"counters\" id=\"counters\" hidden>\n <dt>Uptime</dt><dd id=\"upVal\"></dd>\n <dt>Disk I/O since start</dt><dd id=\"diskVal\"></dd>\n <dt>Network since start</dt><dd id=\"netVal\"></dd>\n </dl>\n\n <div class=\"note fresh\" id=\"fresh\" data-stale=\"0\"></div>\n\n <div class=\"actions\">\n <button class=\"btn\" id=\"refresh\" type=\"button\">Refresh</button>\n <button class=\"btn\" id=\"start\" type=\"button\" hidden>Start</button>\n <button class=\"btn\" id=\"reboot\" type=\"button\" hidden>Reboot</button>\n <button class=\"btn\" id=\"shutdown\" type=\"button\" hidden>Shut down</button>\n <span class=\"note\" id=\"status\" role=\"status\" aria-live=\"polite\"></span>\n </div>\n\n <div>\n <div class=\"label section-head\">Activity</div>\n <div class=\"note\" id=\"activity\">Logs and progress for this server are not available in this card yet. Power actions report only that they were accepted; refresh to see the new state.</div>\n </div>\n\n <div>\n <button class=\"btn quiet\" id=\"appsToggle\" type=\"button\" aria-expanded=\"false\" aria-controls=\"apps\">Show deployed apps</button>\n <div class=\"apps\" id=\"apps\" hidden></div>\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.hostCapabilities || result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n // Past this, the reading is labelled out of date. Measured from when this\n // view received it: the payload carries no time of its own.\n var STALE_MS = 120000;\n var MAX_IPS = 8;\n var MAX_APPS = 50;\n\n var serviceId = null;\n var generation = 0; // bumped on every service switch; late answers for an older one are dropped\n var powerState = '';\n var busy = false;\n var appsLoaded = false;\n var receivedAt = 0; // ms; 0 = no reading for this service yet\n var refreshFailed = false; // the last refresh failed: the reading shown is the old one\n var appTimers = [];\n\n // \u2500\u2500 input hygiene \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Everything below comes from a tool result: treat it as hostile text.\n function clip(value, max) {\n var s = String(value);\n return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;\n }\n\n // A finite number, or null. null/''/booleans/objects are \"not reported\", never 0.\n function num(value) {\n if (typeof value === 'number') { return isFinite(value) ? value : null; }\n if (typeof value === 'string' && /^\\s*-?\\d+(\\.\\d+)?\\s*$/.test(value)) { return Number(value); }\n return null;\n }\n\n function serviceIdOf(args) {\n if (!args || typeof args !== 'object') { return null; }\n // MCP Apps sends the tool arguments under `arguments`; the Impreza Panel\n // shell hands them over directly. Accept both, nothing else.\n var src = (args.arguments && typeof args.arguments === 'object') ? args.arguments : args;\n var raw = src.service_id;\n if (typeof raw === 'number' && raw > 0 && raw % 1 === 0 && raw <= 4294967295) { return raw; }\n if (typeof raw === 'string' && /^[1-9]\\d{0,9}$/.test(raw) && Number(raw) <= 4294967295) { return Number(raw); }\n return null;\n }\n\n // \u2500\u2500 formatting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function gib(bytes) {\n var n = num(bytes);\n if (n === null || n <= 0) { return null; }\n return n / 1073741824;\n }\n\n function size(bytes) {\n var n = num(bytes);\n if (n === null || n < 0) { return null; }\n var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];\n var i = 0;\n while (n >= 1024 && i < units.length - 1) { n = n / 1024; i++; }\n return (i === 0 ? String(Math.round(n)) : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + units[i];\n }\n\n function uptime(seconds) {\n var s = num(seconds);\n if (s === null || s <= 0) { return null; }\n var d = Math.floor(s / 86400);\n var h = Math.floor((s % 86400) / 3600);\n var m = Math.floor((s % 3600) / 60);\n if (d > 0) { return d + 'd ' + h + 'h'; }\n if (h > 0) { return h + 'h ' + m + 'm'; }\n return m + 'm';\n }\n\n function clock(ms) {\n return new Date(ms).toISOString().slice(11, 16) + ' UTC';\n }\n\n function load(pct) {\n if (pct >= 90) { return 'full'; }\n if (pct >= 70) { return 'high'; }\n return 'normal';\n }\n\n // `pct` null draws no bar at all: an empty track would read as 0%.\n // `neutral` keeps the bar in the default colour whatever the number says.\n // Used for the memory reading, which comes from the hypervisor and counts\n // page cache as used: 80% is what a healthy machine looks like there, so the\n // 70%/90% thresholds do not describe anything real and an amber bar would be\n // an alarm with nothing behind it.\n function setMeter(prefix, pct, text, neutral, bad) {\n var fill = el(prefix + 'Fill');\n var track = el(prefix + 'Track');\n var val = el(prefix + 'Val');\n val.textContent = text;\n val.dataset.quality = bad ? 'bad' : 'ok';\n track.setAttribute('aria-valuetext', text);\n if (pct === null) {\n fill.style.width = '0%';\n fill.dataset.load = 'none';\n track.removeAttribute('aria-valuenow');\n return;\n }\n var p = Math.max(0, Math.min(100, pct));\n fill.style.width = p.toFixed(1) + '%';\n fill.dataset.load = neutral ? 'normal' : load(p);\n track.setAttribute('aria-valuenow', p.toFixed(1));\n }\n\n function cpuReading(raw) {\n var n = num(raw);\n if (n === null) { return { pct: null, text: 'not reported' }; }\n if (n < 0 || n > 1) {\n return { pct: null, text: clip(n, 24) + ' as reported (unit unclear, expected 0\u20131)', bad: true };\n }\n var pct = n * 100;\n return { pct: pct, text: pct.toFixed(1) + '% of all vCPUs' };\n }\n\n function memReading(d) {\n var used = gib(d.memory_used);\n var total = gib(d.memory_total);\n // The hosted server sends a `memory` block that says what its own number\n // means. Prefer it; fall back to dividing the raw bytes for a payload that\n // predates it (the local npm server, an older API).\n var info = (d.memory && typeof d.memory === 'object') ? d.memory : null;\n var cached = !!(info && info.includes_cache_and_buffers === true);\n var pct = info ? num(info.percent) : null;\n if (pct !== null && (pct < 0 || pct > 100)) { pct = null; }\n if (pct === null && used !== null && total !== null) { pct = (used / total) * 100; }\n if (used === null || total === null) {\n return pct === null ? { pct: null, text: 'not reported' }\n : { pct: pct, text: pct.toFixed(0) + '%' + (cached ? ' incl. cache' : ''), cached: cached };\n }\n if (used > total * 1.001) {\n return { pct: null, text: used.toFixed(2) + ' GiB used of ' + total.toFixed(2) + ' GiB (inconsistent)', bad: true };\n }\n // \"incl. cache\" is four words that stop the reader concluding the machine\n // is about to run out. Without it this card showed 80% on a box the\n // in-guest agent reports at 20%, and a model reading the same number told\n // a customer to go check swap.\n return {\n pct: pct,\n text: used.toFixed(2) + ' of ' + total.toFixed(2) + ' GiB \u00B7 ' + pct.toFixed(0) + '%' + (cached ? ' incl. cache' : ''),\n cached: cached\n };\n }\n\n function pairOf(block, a, b, aLabel, bLabel) {\n if (!block || typeof block !== 'object') { return 'not reported'; }\n var x = size(block[a]);\n var y = size(block[b]);\n if (x === null && y === null) { return 'not reported'; }\n return (x === null ? 'not reported' : x) + ' ' + aLabel + ' \u00B7 ' + (y === null ? 'not reported' : y) + ' ' + bLabel;\n }\n\n // \u2500\u2500 freshness \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function updateFreshness() {\n var f = el('fresh');\n if (!receivedAt) {\n f.textContent = '';\n f.dataset.stale = '0';\n el('card').dataset.stale = '0';\n return;\n }\n var age = Math.max(0, Date.now() - receivedAt);\n var stale = age > STALE_MS || refreshFailed;\n var mins = Math.floor(age / 60000);\n var ago = mins < 1 ? 'just now' : (mins === 1 ? '1 min ago' : mins + ' min ago');\n f.textContent = stale\n ? 'Last reading ' + ago + ' (' + clock(receivedAt) + ') \u2014 may be out of date. Refresh to check.'\n : 'Updated ' + ago + ' (' + clock(receivedAt) + ').';\n f.dataset.stale = stale ? '1' : '0';\n el('card').dataset.stale = stale ? '1' : '0';\n }\n\n // \u2500\u2500 rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function setPower(state) {\n var s = (typeof state === 'string') ? clip(state.toLowerCase(), 32) : '';\n powerState = s === 'unknown' ? '' : s;\n var chip = el('chip');\n if (powerState === 'running') {\n chip.dataset.state = 'ok';\n chip.textContent = 'Running';\n } else if (powerState === 'stopped') {\n chip.dataset.state = 'off';\n chip.textContent = 'Stopped';\n } else if (powerState === '') {\n chip.dataset.state = 'idle';\n chip.textContent = 'Unknown';\n } else {\n chip.dataset.state = 'pending';\n chip.textContent = powerState;\n }\n\n // Offer only what the current state makes sense for.\n var running = powerState === 'running';\n el('start').hidden = running || powerState === '';\n el('reboot').hidden = !running;\n el('shutdown').hidden = !running;\n disarmAll();\n }\n\n function renderStatus(d) {\n if (!d || typeof d !== 'object' || Array.isArray(d)) {\n fail('The server returned nothing this card can show.');\n return;\n }\n setPower(d.power_state);\n\n var cpu = cpuReading(d.cpu_usage);\n var mem = memReading(d);\n el('meters').hidden = false;\n setMeter('cpu', cpu.pct, cpu.text, false, cpu.bad);\n setMeter('mem', mem.pct, mem.text, mem.cached, mem.bad);\n\n var up = uptime(d.uptime);\n el('upVal').textContent = up || 'not reported';\n el('diskVal').textContent = pairOf(d.disk_io, 'read_bytes', 'write_bytes', 'read', 'written');\n el('netVal').textContent = pairOf(d.network, 'in_bytes', 'out_bytes', 'in', 'out');\n el('counters').hidden = false;\n\n el('sub').textContent = serviceId !== null ? ('Service #' + serviceId) : '';\n receivedAt = Date.now();\n refreshFailed = false;\n updateFreshness();\n Bridge.reportSize();\n }\n\n function renderIdentity(info) {\n if (!info || typeof info !== 'object' || Array.isArray(info)) { return; }\n if (typeof info.hostname === 'string' && info.hostname !== '') { el('host').textContent = clip(info.hostname, 253); }\n\n var res = (info.resources && typeof info.resources === 'object') ? info.resources : {};\n var bits = [];\n var push = function (label, value) {\n if (value === null || value === undefined || value === '') { return; }\n bits.push([label, String(value)]);\n };\n push('Node', typeof info.node === 'string' ? clip(info.node, 64) : null);\n var cores = num(res.cores);\n push('vCPU', cores !== null && cores > 0 ? cores : null);\n var memMb = num(res.memory_mb);\n push('RAM', memMb !== null && memMb > 0 ? (Math.round(memMb / 1024 * 10) / 10) + ' GiB' : null);\n var diskGb = num(res.disk_gb);\n push('Disk', diskGb !== null && diskGb > 0 ? diskGb + ' GB (size)' : null);\n var ips = Array.isArray(info.ips)\n ? info.ips.map(function (i) { return (i && typeof i.ip === 'string') ? clip(i.ip, 45) : ''; }).filter(Boolean)\n : [];\n if (ips.length > MAX_IPS) { ips = ips.slice(0, MAX_IPS).concat(['+' + (ips.length - MAX_IPS) + ' more']); }\n push('IP', ips.join(', '));\n if (typeof info.os === 'string') { push('OS', clip(info.os, 120)); }\n\n var box = el('facts');\n box.textContent = '';\n bits.forEach(function (pair) {\n var span = document.createElement('span');\n span.appendChild(document.createTextNode(pair[0] + ' '));\n var b = document.createElement('b');\n b.textContent = pair[1];\n span.appendChild(b);\n box.appendChild(span);\n });\n box.hidden = bits.length === 0;\n Bridge.reportSize();\n }\n\n function appState(status) {\n var s = String(status || '').toLowerCase();\n if (s === 'running' || s === 'healthy') { return ['ok', s]; }\n if (s === 'failed' || s === 'degraded') { return ['bad', s]; }\n if (s === 'uninstalled') { return ['gone', s]; }\n return ['work', clip(s || 'unknown', 32)];\n }\n\n function clearAppTimers() {\n appTimers.forEach(function (t) { clearTimeout(t); });\n appTimers = [];\n }\n\n function renderApps(list) {\n var box = el('apps');\n box.textContent = '';\n clearAppTimers();\n var rows = Array.isArray(list) ? list.filter(function (d) { return d && typeof d === 'object'; }) : [];\n if (rows.length === 0) {\n var none = document.createElement('div');\n none.className = 'note';\n none.textContent = 'No apps deployed on this server yet.';\n box.appendChild(none);\n Bridge.reportSize();\n return;\n }\n rows.slice(0, MAX_APPS).forEach(function (d) {\n var row = document.createElement('div');\n row.className = 'app';\n\n var left = document.createElement('div');\n var name = document.createElement('div');\n name.className = 'app-name';\n name.textContent = clip(d.app_name || d.name || d.id || '?', 120);\n left.appendChild(name);\n if (typeof d.domain === 'string' && d.domain !== '') {\n var host = document.createElement('div');\n host.className = 'app-host';\n host.textContent = clip(d.domain.replace(/^https?:\\/\\//, ''), 253);\n left.appendChild(host);\n }\n row.appendChild(left);\n\n var runtime = (d.runtime && typeof d.runtime === 'object') ? d.runtime : {};\n var pair = appState(runtime.state || 'unknown');\n var st = document.createElement('div');\n st.className = 'app-state';\n st.dataset.state = pair[0];\n st.textContent = 'Runtime: ' + pair[1];\n st.title = 'Observed: ' + clip(runtime.observed_at || 'not reported', 40) + '. Last operation: '\n + clip(((d.last_operation || {}).status || d.status || 'unknown'), 32) + '. Containers only.';\n if (runtime.state && runtime.state !== 'unknown') {\n var age = num(runtime.age_seconds);\n appTimers.push(setTimeout(function () { st.textContent = 'Runtime: unknown'; st.dataset.state = 'work'; },\n Math.max(0, 120 - (age === null ? 0 : age)) * 1000));\n }\n row.appendChild(st);\n\n box.appendChild(row);\n });\n if (rows.length > MAX_APPS) {\n var more = document.createElement('div');\n more.className = 'note';\n more.textContent = (rows.length - MAX_APPS) + ' more not shown.';\n box.appendChild(more);\n }\n Bridge.reportSize();\n }\n\n function clearReadings() {\n el('meters').hidden = true;\n el('counters').hidden = true;\n ['cpu', 'mem'].forEach(function (p) { setMeter(p, null, '', false, false); });\n ['upVal', 'diskVal', 'netVal'].forEach(function (id) { el(id).textContent = ''; });\n receivedAt = 0;\n refreshFailed = false;\n updateFreshness();\n }\n\n // A new service: nothing of the previous one may stay on screen.\n function resetView() {\n generation++;\n clearAppTimers();\n clearReadings();\n el('host').textContent = '\u2014';\n var facts = el('facts');\n facts.textContent = '';\n facts.hidden = true;\n var apps = el('apps');\n apps.textContent = '';\n apps.hidden = true;\n appsLoaded = false;\n var toggle = el('appsToggle');\n toggle.textContent = 'Show deployed apps';\n toggle.setAttribute('aria-expanded', 'false');\n setPower('');\n el('chip').textContent = 'Loading';\n say('');\n }\n\n function fail(text) {\n clearReadings();\n var chip = el('chip');\n chip.dataset.state = 'error';\n chip.textContent = 'Unavailable';\n el('sub').textContent = '';\n el('start').hidden = true;\n el('reboot').hidden = true;\n el('shutdown').hidden = true;\n say(clip(text || 'Could not load this server.', 300), true);\n }\n\n function say(text, bad) {\n var s = el('status');\n s.className = bad ? 'note warn' : 'note';\n s.textContent = text || '';\n Bridge.reportSize();\n }\n\n // \u2500\u2500 the arm-then-fire pattern for the disruptive actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function disarmAll() {\n ['reboot', 'shutdown'].forEach(function (id) {\n var b = el(id);\n b.dataset.armed = '0';\n b.textContent = b.dataset.base || b.textContent;\n });\n }\n\n function wirePower(id, action, disruptive) {\n var btn = el(id);\n btn.dataset.base = btn.textContent;\n btn.addEventListener('click', function () {\n if (busy || serviceId === null) { return; }\n if (disruptive && btn.dataset.armed !== '1') {\n disarmAll();\n btn.dataset.armed = '1';\n btn.textContent = 'Confirm ' + btn.dataset.base.toLowerCase();\n say('This interrupts everything running on the server. Click again to go ahead.');\n return;\n }\n disarmAll();\n busy = true;\n btn.disabled = true;\n var g = generation;\n say(btn.dataset.base + ' requested\u2026');\n Bridge.callTool('impreza_vps_power', { service_id: serviceId, action: action })\n .then(function () {\n if (g !== generation) { return; }\n say('Accepted. The state below refreshes as the server catches up.');\n setTimeout(function () { if (g === generation) { refreshStatus(); } }, 4000);\n })\n .catch(function (e) { if (g === generation) { say(clip(e.message || 'The host declined that action.', 300), true); } })\n .then(function () { busy = false; btn.disabled = false; Bridge.reportSize(); });\n });\n }\n\n // \u2500\u2500 server calls (reads only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function refreshStatus() {\n if (serviceId === null) { return Promise.resolve(); }\n var g = generation;\n return Bridge.callTool('impreza_vps_status', { service_id: serviceId })\n .then(function (d) { if (g === generation) { renderStatus(d); } })\n .catch(function (e) {\n if (g !== generation) { return; }\n // Keep the last reading: marked old, it is still the best we know.\n var msg = clip(e.message || 'Could not refresh.', 200);\n say(receivedAt ? msg + ' Showing the reading from ' + clock(receivedAt) + '.' : msg, true);\n if (receivedAt) { refreshFailed = true; updateFreshness(); }\n });\n }\n\n function loadIdentity() {\n if (serviceId === null) { return; }\n var g = generation;\n // One enrichment call, for the facts that do not change.\n Bridge.callTool('impreza_api_call', { path: '/vps/proxmox/' + serviceId })\n .then(function (info) { if (g === generation) { renderIdentity(info); } })\n .catch(function () { /* the card is still useful without the hardware strip */ });\n }\n\n function loadApps() {\n // Two calls, and only on demand: list_servers maps this service to its\n // agent, and only then can deployments be filtered to THIS machine.\n // Showing another server's apps would be worse than showing none.\n var g = generation;\n var sid = serviceId;\n return Bridge.callTool('impreza_list_servers', {})\n .then(function (d) {\n if (g !== generation) { return; }\n var servers = (d && Array.isArray(d.servers)) ? d.servers : [];\n var mine = servers.filter(function (s) { return s && String(s.service_id) === String(sid); })[0];\n if (!mine || !mine.agent_id) {\n renderApps([]);\n return;\n }\n return Bridge.callTool('impreza_list_deployments', { agent_id: mine.agent_id })\n .then(function (r) { if (g === generation) { renderApps((r && r.deployments) || []); } });\n })\n .catch(function (e) { if (g === generation) { say(clip(e.message || 'Could not list apps.', 300), true); } });\n }\n\n // \u2500\u2500 wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n el('refresh').addEventListener('click', function () {\n if (busy) { return; }\n say('Refreshing\u2026');\n var g = generation;\n refreshStatus().then(function () {\n if (g === generation && el('status').textContent === 'Refreshing\u2026') { say(''); }\n });\n });\n\n wirePower('start', 'start', false);\n wirePower('reboot', 'reboot', true);\n wirePower('shutdown', 'shutdown', true);\n\n el('appsToggle').addEventListener('click', function () {\n var box = el('apps');\n if (box.hidden) {\n box.hidden = false;\n this.textContent = 'Hide deployed apps';\n this.setAttribute('aria-expanded', 'true');\n if (!appsLoaded && serviceId !== null) { appsLoaded = true; loadApps(); }\n } else {\n box.hidden = true;\n this.textContent = 'Show deployed apps';\n this.setAttribute('aria-expanded', 'false');\n }\n Bridge.reportSize();\n });\n\n setInterval(updateFreshness, 15000);\n\n Bridge.start({\n name: 'impreza-server-card',\n onInput: function (args) {\n // The status result carries no service_id; the tool ARGUMENTS do, and\n // every later call this panel makes needs it.\n var sid = serviceIdOf(args);\n if (sid === null || sid === serviceId) { return; }\n if (serviceId !== null) { resetView(); }\n serviceId = sid;\n el('sub').textContent = 'Service #' + serviceId;\n loadIdentity();\n },\n onResult: renderStatus,\n onFail: fail\n });\n})();\n</script>\n</body>\n</html>\n";
|
|
5
5
|
/** md5 of the canonical lib/ui/server-card.html, LF-normalised. */
|
|
6
|
-
export declare const SERVER_CARD_HTML_MD5 = "
|
|
7
|
-
export declare const DEPLOY_WIZARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Deploy an app</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_list_apps.\n\n Browsing a catalogue, filling three fields and watching a deploy is the\n clearest case in this product for a form over a conversation: the questions\n are fixed, the answers are short, and the progress is a state machine.\n\n Three decisions worth knowing:\n\n \u00B7 The form offers ONLY what the selected app declares it supports. The\n catalogue carries `supports.custom_domain` and `supports.onion` per app, so\n a domain field on an app that cannot take one, or an onion toggle on an app\n with no hidden-service support, would be a promise the deploy then breaks.\n The requirements line is drawn from the app's own numbers for the same\n reason.\n\n \u00B7 No app icons. The catalogue gives `icon_url`, and fetching it would be a\n network request from a panel whose declared CSP has none \u2014 so the tiles use\n a monogram from the app's own name. The constraint shaped the design rather\n than being worked around.\n\n \u00B7 Progress is read from the deployment's own status column via\n impreza_list_deployments, not from a second progress model. That works\n whether or not the host speaks the Tasks extension: a host that does gets a\n task handle back from the deploy, one that does not gets the deployment\n JSON, and polling the list answers both.\n\n This panel deliberately calls no confirmation-gated tool. Those exist because\n intent must be proven across two SEPARATE calls, and a view doing both halves\n on one click would defeat the gate rather than satisfy it.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* \u2500\u2500 search + catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .tiles {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n gap: 6px;\n max-height: 260px;\n overflow-y: auto;\n }\n .tile {\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 8px 9px;\n border: 1px solid var(--color-border-secondary);\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n }\n .tile:hover { border-color: var(--color-border-primary); }\n .tile[aria-pressed=\"true\"] {\n border-color: var(--color-text-secondary);\n background: var(--color-background-secondary);\n }\n .tile:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n /* No icon_url fetch \u2014 a monogram from the app's own name instead. */\n .mono {\n flex: none;\n width: 26px;\n height: 26px;\n display: grid;\n place-items: center;\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n font-family: var(--font-mono);\n font-size: 12px;\n font-weight: 700;\n }\n .tile-name { font-size: 12.5px; font-weight: 600; line-height: 1.25; }\n .tile-ver {\n color: var(--color-text-tertiary);\n font-family: var(--font-mono);\n font-size: 10.5px;\n }\n\n /* \u2500\u2500 the form \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .form { display: flex; flex-direction: column; gap: 10px; }\n .row { display: flex; flex-direction: column; gap: 4px; }\n .needs {\n color: var(--color-text-secondary);\n font-family: var(--font-mono);\n font-size: 11.5px;\n }\n\n /* \u2500\u2500 progress \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .steps { display: flex; flex-direction: column; gap: 5px; }\n .step {\n display: flex;\n align-items: baseline;\n gap: 8px;\n font-size: 12.5px;\n color: var(--color-text-secondary);\n }\n .step b {\n font-family: var(--font-mono);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n min-width: 4.5em;\n }\n .step[data-kind=\"ok\"] b { color: var(--color-text-success); }\n .step[data-kind=\"work\"] b { color: var(--color-text-warning); }\n .step[data-kind=\"bad\"] b { color: var(--color-text-danger); }\n</style>\n</head>\n<body>\n<div class=\"card\">\n <div class=\"head\">\n <div>\n <div class=\"title\" id=\"title\">Deploy an app</div>\n <div class=\"sub\" id=\"sub\">Loading the catalogue\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"idle\" hidden>Idle</div>\n </div>\n\n <!-- \u2500\u2500 pick \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div id=\"pick\" hidden>\n <div class=\"row\" style=\"margin-bottom:8px\">\n <label class=\"label\" for=\"search\">Search the catalogue</label>\n <input type=\"text\" id=\"search\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"name, category or tag\">\n </div>\n <div class=\"tiles\" id=\"tiles\" role=\"listbox\" aria-label=\"Catalogue apps\"></div>\n <div class=\"note\" id=\"none\" hidden style=\"margin-top:8px\">Nothing in the catalogue matches that.</div>\n </div>\n\n <!-- \u2500\u2500 configure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"form\" id=\"form\" hidden>\n <div class=\"needs\" id=\"needs\"></div>\n <div class=\"row\">\n <label class=\"label\" for=\"agent\">Deploy to</label>\n <select id=\"agent\"></select>\n </div>\n <div class=\"row\" id=\"domainRow\">\n <label class=\"label\" for=\"domain\">Domain <span style=\"text-transform:none;letter-spacing:0\">\u2014 leave blank for a free *.imprezaapps.com hostname</span></label>\n <input type=\"text\" id=\"domain\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"app.example.com\">\n </div>\n <label class=\"check\" id=\"onionRow\">\n <input type=\"checkbox\" id=\"onion\">\n <span>Also publish a Tor hidden service (.onion) mirror</span>\n </label>\n <div class=\"actions\">\n <button class=\"btn\" id=\"deploy\" type=\"button\">Deploy</button>\n <button class=\"btn quiet\" id=\"back\" type=\"button\">Pick another app</button>\n <span class=\"note\" id=\"status\"></span>\n </div>\n </div>\n\n <!-- \u2500\u2500 watch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div id=\"progress\" hidden>\n <div class=\"steps\" id=\"steps\"></div>\n <div class=\"field\" id=\"result\" hidden style=\"margin-top:10px\">\n <div class=\"field-head\">\n <span class=\"label\">Live at</span>\n <button class=\"copy\" id=\"copyUrl\" type=\"button\">Copy</button>\n </div>\n <div class=\"value\" id=\"url\"></div>\n </div>\n <div class=\"actions\" style=\"margin-top:10px\">\n <button class=\"btn\" id=\"open\" type=\"button\" hidden>Open</button>\n <button class=\"btn quiet\" id=\"again\" type=\"button\" hidden>Deploy another</button>\n </div>\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n clientInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n var apps = [];\n var servers = [];\n var picked = null;\n var busy = false;\n var watchTimer = null;\n var watchTicks = 0;\n var deploymentId = null;\n\n // \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function monogram(name) {\n var s = String(name || '?').replace(/[^A-Za-z0-9]/g, '');\n return (s.slice(0, 2) || '?').toUpperCase();\n }\n\n function say(text, bad) {\n var s = el('status');\n s.className = bad ? 'note warn' : 'note';\n s.textContent = text || '';\n Bridge.reportSize();\n }\n\n function setChip(state, text) {\n var chip = el('chip');\n chip.hidden = false;\n chip.dataset.state = state;\n chip.textContent = text;\n }\n\n function step(kind, tag, text) {\n var row = document.createElement('div');\n row.className = 'step';\n row.dataset.kind = kind;\n var b = document.createElement('b');\n b.textContent = tag;\n row.appendChild(b);\n var span = document.createElement('span');\n span.textContent = text;\n row.appendChild(span);\n el('steps').appendChild(row);\n Bridge.reportSize();\n }\n\n // \u2500\u2500 catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function matches(app, q) {\n if (!q) { return true; }\n var hay = [app.name, app.display_name, app.category]\n .concat(Array.isArray(app.tags) ? app.tags : [])\n .join(' ')\n .toLowerCase();\n return hay.indexOf(q) !== -1;\n }\n\n function renderTiles() {\n var q = el('search').value.trim().toLowerCase();\n var box = el('tiles');\n box.textContent = '';\n var shown = apps.filter(function (a) { return matches(a, q); });\n shown.forEach(function (app) {\n var b = document.createElement('button');\n b.type = 'button';\n b.className = 'tile';\n b.setAttribute('role', 'option');\n b.setAttribute('aria-pressed', picked && picked.name === app.name ? 'true' : 'false');\n\n var m = document.createElement('span');\n m.className = 'mono';\n m.textContent = monogram(app.name);\n b.appendChild(m);\n\n var wrap = document.createElement('span');\n var n = document.createElement('span');\n n.className = 'tile-name';\n n.textContent = String(app.display_name || app.name || '?');\n wrap.appendChild(n);\n var v = document.createElement('span');\n v.className = 'tile-ver';\n v.textContent = String(app.version || '');\n wrap.appendChild(document.createElement('br'));\n wrap.appendChild(v);\n b.appendChild(wrap);\n\n b.addEventListener('click', function () { select(app); });\n box.appendChild(b);\n });\n el('none').hidden = shown.length > 0;\n Bridge.reportSize();\n }\n\n // \u2500\u2500 configure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function select(app) {\n picked = app;\n el('pick').hidden = true;\n el('form').hidden = false;\n el('title').textContent = 'Deploy ' + String(app.display_name || app.name);\n el('sub').textContent = String(app.category || '') + (app.version ? (' \u00B7 ' + app.version) : '');\n setChip('idle', 'Not started');\n\n var r = app.requirements || {};\n var bits = [];\n if (r.cpu_cores) { bits.push(r.cpu_cores + ' vCPU'); }\n if (r.ram_mb) { bits.push(r.ram_mb + ' MB RAM'); }\n if (r.disk_gb) { bits.push(r.disk_gb + ' GB disk'); }\n if (Array.isArray(r.ports) && r.ports.length) { bits.push('port ' + r.ports.join(', ')); }\n el('needs').textContent = bits.length ? ('Needs ' + bits.join(' \u00B7 ')) : '';\n\n // Only what this app says it supports. Offering a field the deploy then\n // rejects is worse than not offering it.\n var sup = app.supports || {};\n el('domainRow').hidden = sup.custom_domain === false;\n el('onionRow').hidden = !sup.onion;\n if (!sup.onion) { el('onion').checked = false; }\n\n say('');\n Bridge.reportSize();\n }\n\n function renderServers() {\n var sel = el('agent');\n sel.textContent = '';\n var online = servers.filter(function (s) { return s.agent_id; });\n if (online.length === 0) {\n var opt = document.createElement('option');\n opt.value = '';\n opt.textContent = 'No agent-enabled server on this account';\n sel.appendChild(opt);\n sel.disabled = true;\n el('deploy').disabled = true;\n return;\n }\n online.forEach(function (s) {\n var opt = document.createElement('option');\n opt.value = String(s.agent_id);\n var label = String(s.hostname || s.agent_id);\n if (s.status && String(s.status).toLowerCase() !== 'online') {\n label += ' (' + String(s.status) + ')';\n }\n opt.textContent = label;\n sel.appendChild(opt);\n });\n sel.disabled = false;\n el('deploy').disabled = false;\n }\n\n // \u2500\u2500 deploy + watch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function startDeploy() {\n if (busy || !picked) { return; }\n var agentId = el('agent').value;\n if (!agentId) { say('Pick a server with the Impreza agent installed.', true); return; }\n\n var args = { app_name: picked.name, agent_id: agentId };\n var domain = el('domain').value.trim();\n if (domain) { args.domain = domain; }\n if (el('onion').checked) { args.onion = true; }\n\n busy = true;\n el('deploy').disabled = true;\n el('form').hidden = true;\n el('progress').hidden = false;\n el('steps').textContent = '';\n setChip('pending', 'Deploying');\n step('work', 'sent', 'Asked the server to deploy ' + String(picked.display_name || picked.name) + '.');\n\n Bridge.callTool('impreza_deploy_catalog_app', args)\n .then(function (d) {\n // A host that speaks the Tasks extension hands back a task handle\n // instead of the deployment; either way the deployment's own status is\n // what we watch, so both shapes lead to the same place.\n deploymentId = (d && (d.id || d.deployment_id)) || null;\n if (d && d.taskId && !deploymentId) {\n step('work', 'task', 'The host is tracking this as a task; watching the deployment directly.');\n }\n if (d && d.domain) {\n showUrl(d.domain);\n }\n step('work', 'queued', 'Accepted. Pulling images and starting the app \u2014 usually a few minutes.');\n watch();\n })\n .catch(function (e) {\n busy = false;\n setChip('error', 'Failed');\n step('bad', 'error', e.message || 'The deploy was refused.');\n el('again').hidden = false;\n });\n }\n\n function showUrl(domain) {\n var u = String(domain || '');\n if (!u) { return; }\n if (u.indexOf('http') !== 0) { u = 'https://' + u; }\n el('url').textContent = u;\n el('result').hidden = false;\n el('open').hidden = !Bridge.canOpenLinks();\n Bridge.reportSize();\n }\n\n var seenStatus = '';\n function watch() {\n clearTimeout(watchTimer);\n watchTicks++;\n // ~5 minutes at 6s. A deploy that has not settled by then has a real\n // problem, and saying so beats polling forever.\n if (watchTicks > 50) {\n setChip('pending', 'Still working');\n step('work', 'slow', 'Still not settled. Check impreza_list_deployments in a moment.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n\n var agentId = el('agent').value;\n Bridge.callTool('impreza_list_deployments', agentId ? { agent_id: agentId } : {})\n .then(function (d) {\n var rows = (d && d.deployments) || [];\n var row = null;\n if (deploymentId) {\n row = rows.filter(function (r) { return r.id === deploymentId; })[0] || null;\n }\n if (!row) {\n // No id to match on: the newest row for this app is the one we just\n // asked for.\n row = rows\n .filter(function (r) { return r.app_name === picked.name; })\n .sort(function (a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); })[0] || null;\n }\n if (!row) {\n watchTimer = setTimeout(watch, 6000);\n return;\n }\n deploymentId = row.id || deploymentId;\n var st = String(row.status || '').toLowerCase();\n if (st && st !== seenStatus) {\n seenStatus = st;\n if (st !== 'running' && st !== 'failed') { step('work', st, 'Status is now ' + st + '.'); }\n }\n if (row.domain) { showUrl(row.domain); }\n\n if (st === 'running') {\n setChip('ok', 'Running');\n step('ok', 'done', 'Deployment completed. Observed runtime: ' + String((row.runtime || {}).state || 'unknown') + '.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n if (st === 'failed') {\n setChip('error', 'Failed');\n step('bad', 'failed', String(row.last_error || 'The deploy failed.').slice(0, 240) + ' Observed runtime: ' + String((row.runtime || {}).state || 'unknown') + '.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n watchTimer = setTimeout(watch, 6000);\n })\n .catch(function () { watchTimer = setTimeout(watch, 8000); });\n }\n\n function reset() {\n clearTimeout(watchTimer);\n watchTicks = 0;\n seenStatus = '';\n deploymentId = null;\n busy = false;\n picked = null;\n el('progress').hidden = true;\n el('result').hidden = true;\n el('open').hidden = true;\n el('again').hidden = true;\n el('form').hidden = true;\n el('pick').hidden = false;\n el('deploy').disabled = false;\n el('chip').hidden = true;\n el('title').textContent = 'Deploy an app';\n el('sub').textContent = apps.length + ' apps in the catalogue';\n renderTiles();\n }\n\n // \u2500\u2500 wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n el('search').addEventListener('input', renderTiles);\n el('deploy').addEventListener('click', startDeploy);\n el('back').addEventListener('click', reset);\n el('again').addEventListener('click', reset);\n el('copyUrl').addEventListener('click', function () { Bridge.copy(el('url').textContent, this); });\n el('open').addEventListener('click', function () {\n Bridge.openLink(el('url').textContent)\n .catch(function () { say('The host declined to open the link.', true); });\n });\n\n Bridge.start({\n name: 'impreza-deploy-wizard',\n onResult: function (d) {\n apps = (d && d.apps) || [];\n el('pick').hidden = false;\n el('sub').textContent = apps.length + ' apps in the catalogue';\n renderTiles();\n // One enrichment call: the deploy needs a target, and only the server\n // list maps this account's agents.\n Bridge.callTool('impreza_list_servers', {})\n .then(function (r) { servers = (r && r.servers) || []; renderServers(); })\n .catch(function () {\n servers = [];\n renderServers();\n say('Could not list your servers \u2014 reload the panel to try again.', true);\n });\n },\n onFail: function (text) {\n el('sub').textContent = '';\n setChip('error', 'Unavailable');\n say(String(text || 'Could not load the catalogue.'), true);\n }\n });\n})();\n</script>\n</body>\n</html>\n";
|
|
6
|
+
export declare const SERVER_CARD_HTML_MD5 = "fc96f752c99b6e80324fe8e23b6ad854";
|
|
7
|
+
export declare const DEPLOY_WIZARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\n<title>Deploy an app</title>\n<!--\n Impreza Host \u2014 MCP Apps view for impreza_list_apps.\n\n Browsing a catalogue, filling three fields and watching a deploy is the\n clearest case in this product for a form over a conversation: the questions\n are fixed, the answers are short, and the progress is a state machine.\n\n Three decisions worth knowing:\n\n \u00B7 The form offers ONLY what the selected app declares it supports. The\n catalogue carries `supports.custom_domain` and `supports.onion` per app, so\n a domain field on an app that cannot take one, or an onion toggle on an app\n with no hidden-service support, would be a promise the deploy then breaks.\n The requirements line is drawn from the app's own numbers for the same\n reason.\n\n \u00B7 No app icons. The catalogue gives `icon_url`, and fetching it would be a\n network request from a panel whose declared CSP has none \u2014 so the tiles use\n a monogram from the app's own name. The constraint shaped the design rather\n than being worked around.\n\n \u00B7 Progress is read from the deployment's own status column via\n impreza_list_deployments, not from a second progress model. That works\n whether or not the host speaks the Tasks extension: a host that does gets a\n task handle back from the deploy, one that does not gets the deployment\n JSON, and polling the list answers both.\n\n This panel deliberately calls no confirmation-gated tool. Those exist because\n intent must be proven across two SEPARATE calls, and a view doing both halves\n on one click would defeat the gate rather than satisfy it.\n-->\n<style>\n/* Impreza Host \u2014 shared tokens and primitives for every MCP Apps panel.\n *\n * Substituted into each panel's TOKENS marker by McpUi::read() and by\n * tools/gen_ui_assets.php, for the same reason as _bridge.js: a panel may fetch\n * nothing from the network, so the choice was one substitution or three copies.\n * Keeping it in one file is also what makes our panels read as one family\n * rather than three pages that happen to ship together.\n *\n * Host-provided variables win. These fallbacks are what a panel looks like when\n * a host sends none \u2014 which the spec warns is common, along with partial sets.\n * Neutrals carry a warm bias on purpose: a pure mid-grey reads as unconsidered.\n */\n\n:root {\n color-scheme: light dark;\n\n --color-background-primary: light-dark(#fcfcfb, #1b1b1a);\n --color-background-secondary: light-dark(#f4f3ef, #232320);\n --color-background-tertiary: light-dark(#eceae4, #2b2a26);\n --color-text-primary: light-dark(#1a1a17, #f2f1ed);\n --color-text-secondary: light-dark(#6e6d66, #a5a49c);\n --color-text-tertiary: light-dark(#8d8c84, #7e7d76);\n --color-text-warning: light-dark(#8a5a00, #e0a83a);\n --color-text-success: light-dark(#1f6b3a, #5fbf83);\n --color-text-danger: light-dark(#9a2b21, #f08a7e);\n --color-border-primary: light-dark(#e5e4de, #33302b);\n --color-border-secondary: light-dark(#efeee9, #2a2825);\n\n --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n\n --border-radius-sm: 6px;\n --border-radius-md: 10px;\n --border-radius-full: 999px;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 16px;\n max-width: 560px;\n}\n\n/* \u2500\u2500 header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n.title {\n font-family: var(--font-mono);\n font-size: 18px;\n letter-spacing: -0.01em;\n overflow-wrap: anywhere;\n}\n.total {\n font-family: var(--font-mono);\n font-size: 22px;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n}\n.sub {\n margin-top: 2px;\n color: var(--color-text-secondary);\n font-size: 12px;\n}\n\n/* State, and state only \u2014 the one place colour is allowed to catch the eye. */\n.chip {\n flex: none;\n padding: 3px 10px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-full);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.chip[data-state=\"pending\"] { color: var(--color-text-warning); }\n.chip[data-state=\"paid\"],\n.chip[data-state=\"ok\"] { color: var(--color-text-success); }\n.chip[data-state=\"error\"],\n.chip[data-state=\"off\"] { color: var(--color-text-danger); }\n.chip[data-state=\"idle\"] { color: var(--color-text-secondary); }\n\n/* \u2500\u2500 labelled value blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.field {\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-md);\n background: var(--color-background-secondary);\n padding: 10px 12px;\n}\n.field-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 6px;\n}\n.label {\n color: var(--color-text-tertiary);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n.value {\n font-family: var(--font-mono);\n font-size: 15px;\n font-variant-numeric: tabular-nums;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\n/* \u2500\u2500 controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.btn {\n padding: 6px 13px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n.btn:disabled { opacity: 0.5; cursor: default; }\n.btn:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n/* Armed: one more click fires it. Used where a mis-click costs an afternoon. */\n.btn[data-armed=\"1\"] {\n color: var(--color-text-danger);\n border-color: var(--color-text-danger);\n}\n.btn.quiet {\n background: transparent;\n color: var(--color-text-secondary);\n}\n.btn.quiet:hover { color: var(--color-text-primary); }\n.copy {\n flex: none;\n padding: 3px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-secondary);\n font: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n}\n.copy:hover { color: var(--color-text-primary); }\n.copy:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n\ninput[type=\"text\"], select {\n width: 100%;\n padding: 6px 9px;\n border: 1px solid var(--color-border-primary);\n border-radius: var(--border-radius-sm);\n background: var(--color-background-primary);\n color: var(--color-text-primary);\n font: inherit;\n font-size: 13px;\n}\ninput[type=\"text\"] { font-family: var(--font-mono); }\ninput:focus-visible, select:focus-visible {\n outline: 2px solid var(--color-text-secondary);\n outline-offset: 1px;\n}\n.check { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }\n.check input { margin: 0; }\n\n/* \u2500\u2500 notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.note { color: var(--color-text-secondary); font-size: 11.5px; }\n.note strong { color: var(--color-text-primary); font-weight: 600; }\n.warn { color: var(--color-text-danger); }\n.rest { color: var(--color-text-secondary); font-size: 13px; }\n\n[hidden] { display: none !important; }\n\n@media (prefers-reduced-motion: no-preference) {\n .copy, .btn { transition: color 90ms ease, background 90ms ease, border-color 90ms ease; }\n}\n\n\n /* \u2500\u2500 search + catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .tiles {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n gap: 6px;\n max-height: 260px;\n overflow-y: auto;\n }\n .tile {\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 8px 9px;\n border: 1px solid var(--color-border-secondary);\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n }\n .tile:hover { border-color: var(--color-border-primary); }\n .tile[aria-pressed=\"true\"] {\n border-color: var(--color-text-secondary);\n background: var(--color-background-secondary);\n }\n .tile:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }\n /* No icon_url fetch \u2014 a monogram from the app's own name instead. */\n .mono {\n flex: none;\n width: 26px;\n height: 26px;\n display: grid;\n place-items: center;\n border-radius: var(--border-radius-sm);\n background: var(--color-background-tertiary);\n font-family: var(--font-mono);\n font-size: 12px;\n font-weight: 700;\n }\n .tile-name { font-size: 12.5px; font-weight: 600; line-height: 1.25; }\n .tile-ver {\n color: var(--color-text-tertiary);\n font-family: var(--font-mono);\n font-size: 10.5px;\n }\n\n /* \u2500\u2500 the form \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .form { display: flex; flex-direction: column; gap: 10px; }\n .row { display: flex; flex-direction: column; gap: 4px; }\n .needs {\n color: var(--color-text-secondary);\n font-family: var(--font-mono);\n font-size: 11.5px;\n }\n\n /* \u2500\u2500 progress \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .steps { display: flex; flex-direction: column; gap: 5px; }\n .step {\n display: flex;\n align-items: baseline;\n gap: 8px;\n font-size: 12.5px;\n color: var(--color-text-secondary);\n }\n .step b {\n font-family: var(--font-mono);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n min-width: 4.5em;\n }\n .step[data-kind=\"ok\"] b { color: var(--color-text-success); }\n .step[data-kind=\"work\"] b { color: var(--color-text-warning); }\n .step[data-kind=\"bad\"] b { color: var(--color-text-danger); }\n .search-row { margin-bottom: 8px; }\n .empty-note { margin-top: 8px; }\n .label .plain { text-transform: none; letter-spacing: 0; }\n .result-gap { margin-top: 10px; }\n .actions-gap { margin-top: 10px; }\n</style>\n</head>\n<body>\n<div class=\"card\">\n <div class=\"head\">\n <div>\n <div class=\"title\" id=\"title\">Deploy an app</div>\n <div class=\"sub\" id=\"sub\">Loading the catalogue\u2026</div>\n </div>\n <div class=\"chip\" id=\"chip\" data-state=\"idle\" hidden>Idle</div>\n </div>\n\n <!-- \u2500\u2500 pick \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div id=\"pick\" hidden>\n <div class=\"row search-row\">\n <label class=\"label\" for=\"search\">Search the catalogue</label>\n <input type=\"text\" id=\"search\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"name, category or tag\">\n </div>\n <div class=\"tiles\" id=\"tiles\" role=\"listbox\" aria-label=\"Catalogue apps\"></div>\n <div class=\"note empty-note\" id=\"none\" hidden>Nothing in the catalogue matches that.</div>\n </div>\n\n <!-- \u2500\u2500 configure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"form\" id=\"form\" hidden>\n <div class=\"needs\" id=\"needs\"></div>\n <div class=\"row\">\n <label class=\"label\" for=\"agent\">Deploy to</label>\n <select id=\"agent\"></select>\n </div>\n <div class=\"row\" id=\"domainRow\">\n <label class=\"label\" for=\"domain\">Domain <span class=\"plain\">\u2014 leave blank for a free *.imprezaapps.com hostname</span></label>\n <input type=\"text\" id=\"domain\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"app.example.com\">\n </div>\n <label class=\"check\" id=\"onionRow\">\n <input type=\"checkbox\" id=\"onion\">\n <span>Also publish a Tor hidden service (.onion) mirror</span>\n </label>\n <div class=\"actions\">\n <button class=\"btn\" id=\"deploy\" type=\"button\">Deploy</button>\n <button class=\"btn quiet\" id=\"back\" type=\"button\">Pick another app</button>\n <span class=\"note\" id=\"status\"></span>\n </div>\n </div>\n\n <!-- \u2500\u2500 watch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div id=\"progress\" hidden>\n <div class=\"steps\" id=\"steps\"></div>\n <div class=\"field result-gap\" id=\"result\" hidden>\n <div class=\"field-head\">\n <span class=\"label\">Live at</span>\n <button class=\"copy\" id=\"copyUrl\" type=\"button\">Copy</button>\n </div>\n <div class=\"value\" id=\"url\"></div>\n </div>\n <div class=\"actions actions-gap\">\n <button class=\"btn\" id=\"open\" type=\"button\" hidden>Open</button>\n <button class=\"btn quiet\" id=\"again\" type=\"button\" hidden>Deploy another</button>\n </div>\n </div>\n</div>\n\n<script>\n// Impreza Host \u2014 the MCP Apps host bridge, shared by every panel.\n//\n// Substituted into each panel's BRIDGE marker by McpUi::read() and by\n// tools/gen_ui_assets.php. A panel is a standalone HTML document that may fetch\n// nothing from the network, so the only alternatives to substitution were three\n// copies of this file or a build step. One mechanism, one place the verifier has\n// to lint, and the panels stay readable.\n//\n// Rules that hold for every panel and are enforced by tools/verify_mcp_apps.php:\n// \u00B7 No network. Every value arrives over this bridge.\n// \u00B7 Never innerHTML. Tool values reach the DOM through textContent only.\n// \u00B7 Never inject hostContext.styles.css.fonts \u2014 the spec lets a host hand us\n// an @import from a font service and its own applyHostFonts() injects it\n// verbatim, which on our panels is a third-party request identifying the\n// viewer. Colour and radius VARIABLES yes; the host's font CSS never.\n\n'use strict';\n\nvar Bridge = (function () {\n var nextId = 1;\n var pending = Object.create(null);\n var handlers = Object.create(null);\n var hostCaps = {};\n\n window.addEventListener('message', function (event) {\n var msg = event.data;\n if (!msg || msg.jsonrpc !== '2.0') { return; }\n if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {\n var p = pending[msg.id];\n delete pending[msg.id];\n if (msg.error) { p.reject(new Error(msg.error.message || 'request failed')); }\n else { p.resolve(msg.result); }\n return;\n }\n if (msg.method && handlers[msg.method]) {\n // A malformed notification must not take the panel down with it.\n try { handlers[msg.method](msg.params || {}); } catch (e) { /* ignored */ }\n }\n });\n\n function request(method, params) {\n var id = nextId++;\n return new Promise(function (resolve, reject) {\n pending[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');\n });\n }\n\n function notify(method, params) {\n window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');\n }\n\n function on(method, fn) { handlers[method] = fn; }\n\n /**\n * Call one of OUR tools through the host. The host decides whether to\n * forward it and may ask the viewer first \u2014 a panel never assumes consent.\n * Resolves with the tool's structuredContent when it has one, falling back to\n * parsing the text block for a host that only forwards `content`.\n */\n function callTool(name, args) {\n return request('tools/call', { name: name, arguments: args || {} }).then(function (res) {\n res = res || {};\n if (res.isError) {\n var text = (res.content && res.content[0] && res.content[0].text) || 'The tool call failed.';\n throw new Error(text);\n }\n return unwrap(res);\n });\n }\n\n /** structuredContent if present, else the text block parsed as JSON. */\n function unwrap(payload) {\n if (!payload) { return null; }\n if (payload.structuredContent) { return payload.structuredContent; }\n var t = payload.content && payload.content[0] && payload.content[0].text;\n if (typeof t === 'string') {\n try { return JSON.parse(t); } catch (e) { return null; }\n }\n return null;\n }\n\n function applyStyles(styles) {\n if (!styles || !styles.variables) { return; }\n Object.keys(styles.variables).forEach(function (k) {\n var v = styles.variables[k];\n // Only custom properties, and only plain values: a variable is otherwise\n // a place a host could smuggle CSS into our document.\n if (k.indexOf('--') === 0 && typeof v === 'string' && v.indexOf('}') === -1 && v.indexOf('@') === -1) {\n document.documentElement.style.setProperty(k, v);\n }\n });\n // styles.css.fonts is DELIBERATELY ignored. See the note at the top.\n }\n\n function applyTheme(theme) {\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.colorScheme = theme;\n }\n }\n\n var lastH = 0;\n var ready = false;\n function reportSize() {\n // Nothing is said about size until the handshake is done. The observer can\n // fire before ui/initialize resolves; that early notification used to latch\n // lastH, so the reportSize() after initialized became a no-op and a host\n // that drops pre-handshake messages never heard a size at all. No argument\n // is read here: ResizeObserver passes its entries. An initial 0 stays\n // silent: announcing it would collapse a host that is waiting for content.\n if (!ready) { return; }\n var h = Math.ceil(document.documentElement.scrollHeight);\n var w = Math.ceil(document.documentElement.scrollWidth);\n if (h === lastH) { return; }\n lastH = h;\n notify('ui/notifications/size-changed', { width: w, height: h });\n }\n\n /**\n * Handshake, then hand the panel its data.\n *\n * onResult receives the tool result (unwrapped), onInput the tool arguments,\n * onFail a message when there is no bridge or the call was cancelled.\n */\n function start(opts) {\n opts = opts || {};\n var onResult = opts.onResult || function () {};\n var onInput = opts.onInput || function () {};\n var onFail = opts.onFail || function () {};\n\n on('ui/notifications/tool-input', function (params) { onInput(params || {}); reportSize(); });\n on('ui/notifications/tool-result', function (params) {\n if (params && params.isError) {\n var text = (params.content && params.content[0] && params.content[0].text) || '';\n onFail(text || 'The tool call failed.');\n return;\n }\n var data = unwrap(params);\n if (data) { onResult(data); } else { onFail('The tool returned nothing this panel can render.'); }\n reportSize();\n });\n on('ui/notifications/tool-cancelled', function () { onFail('The request was cancelled.'); });\n on('ui/notifications/host-context-changed', function (params) {\n if (!params) { return; }\n if (params.theme) { applyTheme(params.theme); }\n if (params.styles) { applyStyles(params.styles); }\n });\n\n if (window.ResizeObserver) {\n new ResizeObserver(reportSize).observe(document.body);\n }\n\n return request('ui/initialize', {\n protocolVersion: '2026-01-26',\n appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },\n appCapabilities: {}\n }).then(function (result) {\n result = result || {};\n hostCaps = result.hostCapabilities || result.capabilities || {};\n var ctx = result.hostContext || {};\n applyTheme(ctx.theme);\n applyStyles(ctx.styles);\n notify('ui/notifications/initialized', {});\n ready = true;\n reportSize();\n return { capabilities: hostCaps, hostContext: ctx };\n }, function () {\n // Standalone, with no host on the other end. Saying so beats a spinner\n // that never resolves.\n onFail('This panel needs an MCP host to load its data.');\n throw new Error('no host bridge');\n });\n }\n\n /** Ask the host to open a URL. Only offered when it says it can. */\n function openLink(url) {\n return request('ui/open-link', { url: String(url) });\n }\n\n function canOpenLinks() { return !!hostCaps.openLinks; }\n\n /** Copy text, with a fallback because permissions are never guaranteed. */\n function copy(text, button, okLabel, failLabel) {\n var done = function (ok) {\n var original = button.dataset.label || button.textContent;\n button.dataset.label = original;\n button.textContent = ok ? (okLabel || 'Copied') : (failLabel || 'Select it');\n setTimeout(function () { button.textContent = button.dataset.label; }, 1400);\n };\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n return;\n }\n done(false);\n }\n\n return {\n request: request,\n notify: notify,\n on: on,\n callTool: callTool,\n unwrap: unwrap,\n start: start,\n reportSize: reportSize,\n openLink: openLink,\n canOpenLinks: canOpenLinks,\n copy: copy\n };\n})();\n\n/** Shorthand every panel uses. */\nfunction el(id) { return document.getElementById(id); }\n\n\n(function () {\n 'use strict';\n\n var apps = [];\n var servers = [];\n var picked = null;\n var busy = false;\n var watchTimer = null;\n var watchTicks = 0;\n var deploymentId = null;\n\n // \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function monogram(name) {\n var s = String(name || '?').replace(/[^A-Za-z0-9]/g, '');\n return (s.slice(0, 2) || '?').toUpperCase();\n }\n\n function say(text, bad) {\n var s = el('status');\n s.className = bad ? 'note warn' : 'note';\n s.textContent = text || '';\n Bridge.reportSize();\n }\n\n function setChip(state, text) {\n var chip = el('chip');\n chip.hidden = false;\n chip.dataset.state = state;\n chip.textContent = text;\n }\n\n function step(kind, tag, text) {\n var row = document.createElement('div');\n row.className = 'step';\n row.dataset.kind = kind;\n var b = document.createElement('b');\n b.textContent = tag;\n row.appendChild(b);\n var span = document.createElement('span');\n span.textContent = text;\n row.appendChild(span);\n el('steps').appendChild(row);\n Bridge.reportSize();\n }\n\n // \u2500\u2500 catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function matches(app, q) {\n if (!q) { return true; }\n var hay = [app.name, app.display_name, app.category]\n .concat(Array.isArray(app.tags) ? app.tags : [])\n .join(' ')\n .toLowerCase();\n return hay.indexOf(q) !== -1;\n }\n\n function renderTiles() {\n var q = el('search').value.trim().toLowerCase();\n var box = el('tiles');\n box.textContent = '';\n var shown = apps.filter(function (a) { return matches(a, q); });\n shown.forEach(function (app) {\n var b = document.createElement('button');\n b.type = 'button';\n b.className = 'tile';\n b.setAttribute('role', 'option');\n b.setAttribute('aria-pressed', picked && picked.name === app.name ? 'true' : 'false');\n\n var m = document.createElement('span');\n m.className = 'mono';\n m.textContent = monogram(app.name);\n b.appendChild(m);\n\n var wrap = document.createElement('span');\n var n = document.createElement('span');\n n.className = 'tile-name';\n n.textContent = String(app.display_name || app.name || '?');\n wrap.appendChild(n);\n var v = document.createElement('span');\n v.className = 'tile-ver';\n v.textContent = String(app.version || '');\n wrap.appendChild(document.createElement('br'));\n wrap.appendChild(v);\n b.appendChild(wrap);\n\n b.addEventListener('click', function () { select(app); });\n box.appendChild(b);\n });\n el('none').hidden = shown.length > 0;\n Bridge.reportSize();\n }\n\n // \u2500\u2500 configure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function select(app) {\n picked = app;\n el('pick').hidden = true;\n el('form').hidden = false;\n el('title').textContent = 'Deploy ' + String(app.display_name || app.name);\n el('sub').textContent = String(app.category || '') + (app.version ? (' \u00B7 ' + app.version) : '');\n setChip('idle', 'Not started');\n\n var r = app.requirements || {};\n var bits = [];\n if (r.cpu_cores) { bits.push(r.cpu_cores + ' vCPU'); }\n if (r.ram_mb) { bits.push(r.ram_mb + ' MB RAM'); }\n if (r.disk_gb) { bits.push(r.disk_gb + ' GB disk'); }\n if (Array.isArray(r.ports) && r.ports.length) { bits.push('port ' + r.ports.join(', ')); }\n el('needs').textContent = bits.length ? ('Needs ' + bits.join(' \u00B7 ')) : '';\n\n // Only what this app says it supports. Offering a field the deploy then\n // rejects is worse than not offering it.\n var sup = app.supports || {};\n el('domainRow').hidden = sup.custom_domain === false;\n el('onionRow').hidden = !sup.onion;\n if (!sup.onion) { el('onion').checked = false; }\n\n say('');\n Bridge.reportSize();\n }\n\n function renderServers() {\n var sel = el('agent');\n sel.textContent = '';\n var online = servers.filter(function (s) { return s.agent_id; });\n if (online.length === 0) {\n var opt = document.createElement('option');\n opt.value = '';\n opt.textContent = 'No agent-enabled server on this account';\n sel.appendChild(opt);\n sel.disabled = true;\n el('deploy').disabled = true;\n return;\n }\n online.forEach(function (s) {\n var opt = document.createElement('option');\n opt.value = String(s.agent_id);\n var label = String(s.hostname || s.agent_id);\n if (s.status && String(s.status).toLowerCase() !== 'online') {\n label += ' (' + String(s.status) + ')';\n }\n opt.textContent = label;\n sel.appendChild(opt);\n });\n sel.disabled = false;\n el('deploy').disabled = false;\n }\n\n // \u2500\u2500 deploy + watch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function startDeploy() {\n if (busy || !picked) { return; }\n var agentId = el('agent').value;\n if (!agentId) { say('Pick a server with the Impreza agent installed.', true); return; }\n\n var args = { app_name: picked.name, agent_id: agentId };\n var domain = el('domain').value.trim();\n if (domain) { args.domain = domain; }\n if (el('onion').checked) { args.onion = true; }\n\n busy = true;\n el('deploy').disabled = true;\n el('form').hidden = true;\n el('progress').hidden = false;\n el('steps').textContent = '';\n setChip('pending', 'Deploying');\n step('work', 'sent', 'Asked the server to deploy ' + String(picked.display_name || picked.name) + '.');\n\n Bridge.callTool('impreza_deploy_catalog_app', args)\n .then(function (d) {\n // A host that speaks the Tasks extension hands back a task handle\n // instead of the deployment; either way the deployment's own status is\n // what we watch, so both shapes lead to the same place.\n deploymentId = (d && (d.id || d.deployment_id)) || null;\n if (d && d.taskId && !deploymentId) {\n step('work', 'task', 'The host is tracking this as a task; watching the deployment directly.');\n }\n if (d && d.domain) {\n showUrl(d.domain);\n }\n step('work', 'queued', 'Accepted. Pulling images and starting the app \u2014 usually a few minutes.');\n watch();\n })\n .catch(function (e) {\n busy = false;\n setChip('error', 'Failed');\n step('bad', 'error', e.message || 'The deploy was refused.');\n el('again').hidden = false;\n });\n }\n\n function showUrl(domain) {\n var u = String(domain || '');\n if (!u) { return; }\n if (u.indexOf('http') !== 0) { u = 'https://' + u; }\n el('url').textContent = u;\n el('result').hidden = false;\n el('open').hidden = !Bridge.canOpenLinks();\n Bridge.reportSize();\n }\n\n var seenStatus = '';\n function watch() {\n clearTimeout(watchTimer);\n watchTicks++;\n // ~5 minutes at 6s. A deploy that has not settled by then has a real\n // problem, and saying so beats polling forever.\n if (watchTicks > 50) {\n setChip('pending', 'Still working');\n step('work', 'slow', 'Still not settled. Check impreza_list_deployments in a moment.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n\n var agentId = el('agent').value;\n Bridge.callTool('impreza_list_deployments', agentId ? { agent_id: agentId } : {})\n .then(function (d) {\n var rows = (d && d.deployments) || [];\n var row = null;\n if (deploymentId) {\n row = rows.filter(function (r) { return r.id === deploymentId; })[0] || null;\n }\n if (!row) {\n // No id to match on: the newest row for this app is the one we just\n // asked for.\n row = rows\n .filter(function (r) { return r.app_name === picked.name; })\n .sort(function (a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); })[0] || null;\n }\n if (!row) {\n watchTimer = setTimeout(watch, 6000);\n return;\n }\n deploymentId = row.id || deploymentId;\n var st = String(row.status || '').toLowerCase();\n if (st && st !== seenStatus) {\n seenStatus = st;\n if (st !== 'running' && st !== 'failed') { step('work', st, 'Status is now ' + st + '.'); }\n }\n if (row.domain) { showUrl(row.domain); }\n\n if (st === 'running') {\n setChip('ok', 'Running');\n step('ok', 'done', 'Deployment completed. Observed runtime: ' + String((row.runtime || {}).state || 'unknown') + '.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n if (st === 'failed') {\n setChip('error', 'Failed');\n step('bad', 'failed', String(row.last_error || 'The deploy failed.').slice(0, 240) + ' Observed runtime: ' + String((row.runtime || {}).state || 'unknown') + '.');\n busy = false;\n el('again').hidden = false;\n return;\n }\n watchTimer = setTimeout(watch, 6000);\n })\n .catch(function () { watchTimer = setTimeout(watch, 8000); });\n }\n\n function reset() {\n clearTimeout(watchTimer);\n watchTicks = 0;\n seenStatus = '';\n deploymentId = null;\n busy = false;\n picked = null;\n el('progress').hidden = true;\n el('result').hidden = true;\n el('open').hidden = true;\n el('again').hidden = true;\n el('form').hidden = true;\n el('pick').hidden = false;\n el('deploy').disabled = false;\n el('chip').hidden = true;\n el('title').textContent = 'Deploy an app';\n el('sub').textContent = apps.length + ' apps in the catalogue';\n renderTiles();\n }\n\n // \u2500\u2500 wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n el('search').addEventListener('input', renderTiles);\n el('deploy').addEventListener('click', startDeploy);\n el('back').addEventListener('click', reset);\n el('again').addEventListener('click', reset);\n el('copyUrl').addEventListener('click', function () { Bridge.copy(el('url').textContent, this); });\n el('open').addEventListener('click', function () {\n Bridge.openLink(el('url').textContent)\n .catch(function () { say('The host declined to open the link.', true); });\n });\n\n Bridge.start({\n name: 'impreza-deploy-wizard',\n onResult: function (d) {\n apps = (d && d.apps) || [];\n el('pick').hidden = false;\n el('sub').textContent = apps.length + ' apps in the catalogue';\n renderTiles();\n // One enrichment call: the deploy needs a target, and only the server\n // list maps this account's agents.\n Bridge.callTool('impreza_list_servers', {})\n .then(function (r) { servers = (r && r.servers) || []; renderServers(); })\n .catch(function () {\n servers = [];\n renderServers();\n say('Could not list your servers \u2014 reload the panel to try again.', true);\n });\n },\n onFail: function (text) {\n el('sub').textContent = '';\n setChip('error', 'Unavailable');\n say(String(text || 'Could not load the catalogue.'), true);\n }\n });\n})();\n</script>\n</body>\n</html>\n";
|
|
8
8
|
/** md5 of the canonical lib/ui/deploy-wizard.html, LF-normalised. */
|
|
9
|
-
export declare const DEPLOY_WIZARD_HTML_MD5 = "
|
|
9
|
+
export declare const DEPLOY_WIZARD_HTML_MD5 = "6ef2a2ee550065aac5eebce7507b089c";
|
package/dist/ui-assets.js
CHANGED
|
@@ -256,6 +256,7 @@ input:focus-visible, select:focus-visible {
|
|
|
256
256
|
#paymentQR { margin:12px 0; max-width:256px; }
|
|
257
257
|
.value { overflow-wrap:anywhere; }
|
|
258
258
|
.actions { flex-wrap:wrap; }
|
|
259
|
+
.amount-gap { margin-bottom: 10px; }
|
|
259
260
|
.rail:focus-visible { outline: 2px solid var(--color-text-secondary); outline-offset: 1px; }
|
|
260
261
|
</style>
|
|
261
262
|
</head>
|
|
@@ -272,7 +273,7 @@ input:focus-visible, select:focus-visible {
|
|
|
272
273
|
<div class="rails" id="rails" hidden role="tablist" aria-label="Payment currency"></div>
|
|
273
274
|
|
|
274
275
|
<div id="pay" hidden>
|
|
275
|
-
<div class="field
|
|
276
|
+
<div class="field amount-gap">
|
|
276
277
|
<div class="field-head">
|
|
277
278
|
<span class="label">Send exactly</span>
|
|
278
279
|
<button class="copy" id="copyAmount" type="button">Copy</button>
|
|
@@ -465,11 +466,11 @@ var Bridge = (function () {
|
|
|
465
466
|
|
|
466
467
|
return request('ui/initialize', {
|
|
467
468
|
protocolVersion: '2026-01-26',
|
|
468
|
-
|
|
469
|
+
appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
|
|
469
470
|
appCapabilities: {}
|
|
470
471
|
}).then(function (result) {
|
|
471
472
|
result = result || {};
|
|
472
|
-
hostCaps = result.capabilities || {};
|
|
473
|
+
hostCaps = result.hostCapabilities || result.capabilities || {};
|
|
473
474
|
var ctx = result.hostContext || {};
|
|
474
475
|
applyTheme(ctx.theme);
|
|
475
476
|
applyStyles(ctx.styles);
|
|
@@ -1031,7 +1032,7 @@ function el(id) { return document.getElementById(id); }
|
|
|
1031
1032
|
</html>
|
|
1032
1033
|
`;
|
|
1033
1034
|
/** md5 of the canonical lib/ui/topup-card.html, LF-normalised. */
|
|
1034
|
-
export const TOPUP_CARD_HTML_MD5 = '
|
|
1035
|
+
export const TOPUP_CARD_HTML_MD5 = 'da28ab3578d56e7b59d771787055d2d4';
|
|
1035
1036
|
export const SERVER_CARD_HTML = `<!DOCTYPE html>
|
|
1036
1037
|
<html lang="en">
|
|
1037
1038
|
<head>
|
|
@@ -1359,7 +1360,7 @@ input:focus-visible, select:focus-visible {
|
|
|
1359
1360
|
.fresh[data-stale="1"] { color: var(--color-text-warning); }
|
|
1360
1361
|
|
|
1361
1362
|
/* ── deployments ──────────────────────────────────────────────────────── */
|
|
1362
|
-
.apps { display: flex; flex-direction: column; gap: 6px; }
|
|
1363
|
+
.apps { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
|
1363
1364
|
.app {
|
|
1364
1365
|
display: flex;
|
|
1365
1366
|
align-items: baseline;
|
|
@@ -1440,7 +1441,7 @@ input:focus-visible, select:focus-visible {
|
|
|
1440
1441
|
|
|
1441
1442
|
<div>
|
|
1442
1443
|
<button class="btn quiet" id="appsToggle" type="button" aria-expanded="false" aria-controls="apps">Show deployed apps</button>
|
|
1443
|
-
<div class="apps" id="apps" hidden
|
|
1444
|
+
<div class="apps" id="apps" hidden></div>
|
|
1444
1445
|
</div>
|
|
1445
1446
|
</div>
|
|
1446
1447
|
|
|
@@ -1599,11 +1600,11 @@ var Bridge = (function () {
|
|
|
1599
1600
|
|
|
1600
1601
|
return request('ui/initialize', {
|
|
1601
1602
|
protocolVersion: '2026-01-26',
|
|
1602
|
-
|
|
1603
|
+
appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
|
|
1603
1604
|
appCapabilities: {}
|
|
1604
1605
|
}).then(function (result) {
|
|
1605
1606
|
result = result || {};
|
|
1606
|
-
hostCaps = result.capabilities || {};
|
|
1607
|
+
hostCaps = result.hostCapabilities || result.capabilities || {};
|
|
1607
1608
|
var ctx = result.hostContext || {};
|
|
1608
1609
|
applyTheme(ctx.theme);
|
|
1609
1610
|
applyStyles(ctx.styles);
|
|
@@ -2175,7 +2176,7 @@ function el(id) { return document.getElementById(id); }
|
|
|
2175
2176
|
</html>
|
|
2176
2177
|
`;
|
|
2177
2178
|
/** md5 of the canonical lib/ui/server-card.html, LF-normalised. */
|
|
2178
|
-
export const SERVER_CARD_HTML_MD5 = '
|
|
2179
|
+
export const SERVER_CARD_HTML_MD5 = 'fc96f752c99b6e80324fe8e23b6ad854';
|
|
2179
2180
|
export const DEPLOY_WIZARD_HTML = `<!DOCTYPE html>
|
|
2180
2181
|
<html lang="en">
|
|
2181
2182
|
<head>
|
|
@@ -2490,6 +2491,11 @@ input:focus-visible, select:focus-visible {
|
|
|
2490
2491
|
.step[data-kind="ok"] b { color: var(--color-text-success); }
|
|
2491
2492
|
.step[data-kind="work"] b { color: var(--color-text-warning); }
|
|
2492
2493
|
.step[data-kind="bad"] b { color: var(--color-text-danger); }
|
|
2494
|
+
.search-row { margin-bottom: 8px; }
|
|
2495
|
+
.empty-note { margin-top: 8px; }
|
|
2496
|
+
.label .plain { text-transform: none; letter-spacing: 0; }
|
|
2497
|
+
.result-gap { margin-top: 10px; }
|
|
2498
|
+
.actions-gap { margin-top: 10px; }
|
|
2493
2499
|
</style>
|
|
2494
2500
|
</head>
|
|
2495
2501
|
<body>
|
|
@@ -2504,12 +2510,12 @@ input:focus-visible, select:focus-visible {
|
|
|
2504
2510
|
|
|
2505
2511
|
<!-- ── pick ────────────────────────────────────────────────────────── -->
|
|
2506
2512
|
<div id="pick" hidden>
|
|
2507
|
-
<div class="row
|
|
2513
|
+
<div class="row search-row">
|
|
2508
2514
|
<label class="label" for="search">Search the catalogue</label>
|
|
2509
2515
|
<input type="text" id="search" autocomplete="off" spellcheck="false" placeholder="name, category or tag">
|
|
2510
2516
|
</div>
|
|
2511
2517
|
<div class="tiles" id="tiles" role="listbox" aria-label="Catalogue apps"></div>
|
|
2512
|
-
<div class="note" id="none" hidden
|
|
2518
|
+
<div class="note empty-note" id="none" hidden>Nothing in the catalogue matches that.</div>
|
|
2513
2519
|
</div>
|
|
2514
2520
|
|
|
2515
2521
|
<!-- ── configure ───────────────────────────────────────────────────── -->
|
|
@@ -2520,7 +2526,7 @@ input:focus-visible, select:focus-visible {
|
|
|
2520
2526
|
<select id="agent"></select>
|
|
2521
2527
|
</div>
|
|
2522
2528
|
<div class="row" id="domainRow">
|
|
2523
|
-
<label class="label" for="domain">Domain <span
|
|
2529
|
+
<label class="label" for="domain">Domain <span class="plain">— leave blank for a free *.imprezaapps.com hostname</span></label>
|
|
2524
2530
|
<input type="text" id="domain" autocomplete="off" spellcheck="false" placeholder="app.example.com">
|
|
2525
2531
|
</div>
|
|
2526
2532
|
<label class="check" id="onionRow">
|
|
@@ -2537,14 +2543,14 @@ input:focus-visible, select:focus-visible {
|
|
|
2537
2543
|
<!-- ── watch ───────────────────────────────────────────────────────── -->
|
|
2538
2544
|
<div id="progress" hidden>
|
|
2539
2545
|
<div class="steps" id="steps"></div>
|
|
2540
|
-
<div class="field" id="result" hidden
|
|
2546
|
+
<div class="field result-gap" id="result" hidden>
|
|
2541
2547
|
<div class="field-head">
|
|
2542
2548
|
<span class="label">Live at</span>
|
|
2543
2549
|
<button class="copy" id="copyUrl" type="button">Copy</button>
|
|
2544
2550
|
</div>
|
|
2545
2551
|
<div class="value" id="url"></div>
|
|
2546
2552
|
</div>
|
|
2547
|
-
<div class="actions
|
|
2553
|
+
<div class="actions actions-gap">
|
|
2548
2554
|
<button class="btn" id="open" type="button" hidden>Open</button>
|
|
2549
2555
|
<button class="btn quiet" id="again" type="button" hidden>Deploy another</button>
|
|
2550
2556
|
</div>
|
|
@@ -2706,11 +2712,11 @@ var Bridge = (function () {
|
|
|
2706
2712
|
|
|
2707
2713
|
return request('ui/initialize', {
|
|
2708
2714
|
protocolVersion: '2026-01-26',
|
|
2709
|
-
|
|
2715
|
+
appInfo: { name: opts.name || 'impreza-panel', version: '1.0.0' },
|
|
2710
2716
|
appCapabilities: {}
|
|
2711
2717
|
}).then(function (result) {
|
|
2712
2718
|
result = result || {};
|
|
2713
|
-
hostCaps = result.capabilities || {};
|
|
2719
|
+
hostCaps = result.hostCapabilities || result.capabilities || {};
|
|
2714
2720
|
var ctx = result.hostContext || {};
|
|
2715
2721
|
applyTheme(ctx.theme);
|
|
2716
2722
|
applyStyles(ctx.styles);
|
|
@@ -3084,5 +3090,5 @@ function el(id) { return document.getElementById(id); }
|
|
|
3084
3090
|
</html>
|
|
3085
3091
|
`;
|
|
3086
3092
|
/** md5 of the canonical lib/ui/deploy-wizard.html, LF-normalised. */
|
|
3087
|
-
export const DEPLOY_WIZARD_HTML_MD5 = '
|
|
3093
|
+
export const DEPLOY_WIZARD_HTML_MD5 = '6ef2a2ee550065aac5eebce7507b089c';
|
|
3088
3094
|
//# sourceMappingURL=ui-assets.js.map
|
package/dist/ui-assets.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui-assets.js","sourceRoot":"","sources":["../src/ui-assets.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,EAAE;AACF,wEAAwE;AACxE,oCAAoC;AACpC,EAAE;AACF,mFAAmF;AACnF,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,+EAA+E;AAC/E,wEAAwE;AACxE,kEAAkE;AAElE,MAAM,CAAC,MAAM,eAAe,GAAG
|
|
1
|
+
{"version":3,"file":"ui-assets.js","sourceRoot":"","sources":["../src/ui-assets.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,EAAE;AACF,wEAAwE;AACxE,oCAAoC;AACpC,EAAE;AACF,mFAAmF;AACnF,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,+EAA+E;AAC/E,wEAAwE;AACxE,kEAAkE;AAElE,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4/B9B,CAAC;AAEF,kEAAkE;AAClE,MAAM,CAAC,MAAM,mBAAmB,GAAG,kCAAkC,CAAC;AAEtE,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqnC/B,CAAC;AAEF,mEAAmE;AACnE,MAAM,CAAC,MAAM,oBAAoB,GAAG,kCAAkC,CAAC;AAEvE,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+4BjC,CAAC;AAEF,qEAAqE;AACrE,MAAM,CAAC,MAAM,sBAAsB,GAAG,kCAAkC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "impreza-mcp",
|
|
3
3
|
"mcpName": "io.github.imprezahost/impreza-mcp",
|
|
4
|
-
"version": "0.55.
|
|
4
|
+
"version": "0.55.1",
|
|
5
5
|
"description": "Model Context Protocol server for Impreza Host — privacy-first, no-KYC hosting an AI agent can run end-to-end: self-onboard its own anonymous account (no e-mail), fund it in Bitcoin/Monero, then deploy customer-built apps to managed Impreza VPSes and manage domains/DNS & VPS lifecycle, all without leaving the chat.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|