feat: implement tools management system with UI and API integration
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
.tool-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||||
|
.tool-choice { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: #ffffff75; cursor: pointer; }
|
||||||
|
.tool-choice:hover { background: #fff; }
|
||||||
|
.tool-choice.is-selected { background: var(--yellow-soft); border-color: #ded09a; }
|
||||||
|
.tool-choice > span { display: grid; gap: 2px; min-width: 0; }
|
||||||
|
.tool-choice strong { font-size: 11px; }
|
||||||
|
.tool-choice small { color: var(--muted); font-size: 9px; }
|
||||||
|
.tool-choice input, .tool-card input { flex: 0 0 auto; }
|
||||||
|
.tools-section-heading { margin-block: 7px 15px; }
|
||||||
|
.tools-section-heading .button-row { justify-content: flex-end; }
|
||||||
|
.tools-section-heading .button-row > span { font-size: 10px; }
|
||||||
|
.tools-note { min-height: 22px; margin-bottom: 12px; }
|
||||||
|
.tools-error { margin-bottom: 12px; }
|
||||||
|
.tools-error:empty { display: none; }
|
||||||
|
.tools-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.tool-card { display: flex; flex-direction: column; min-width: 0; padding: 18px; border-radius: 18px; border: 1px solid #ffffffbd; background: #faf9fcbf; box-shadow: var(--shadow-card); }
|
||||||
|
.tool-card-heading { display: flex; gap: 11px; align-items: center; cursor: pointer; }
|
||||||
|
.tool-card-heading > span { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.tool-card-heading strong { font-size: 14px; letter-spacing: -.02em; }
|
||||||
|
.tool-card-heading small { font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-card > p { margin: 12px 0 5px; color: var(--muted); font-size: 10px; }
|
||||||
|
.tool-source { font-size: 8px; color: var(--faint); margin-bottom: 17px; }
|
||||||
|
.tool-card-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: auto; }
|
||||||
|
.tool-installed { border-color: #b8c6ae70; }
|
||||||
|
.tool-installed button:disabled { cursor: default; background: var(--sage-soft); color: #64705c; opacity: 1; }
|
||||||
|
.tool-state { font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-state-good { color: #68765c; }
|
||||||
|
.tool-state-error { color: #a45f57; }
|
||||||
|
.tool-state-active { color: #827037; }
|
||||||
|
.tool-state-active::before { content: ''; display: inline-block; width: 5px; height: 5px; margin-right: 5px; border-radius: 50%; background: var(--yellow); animation: tool-pulse 1.5s ease-in-out infinite; }
|
||||||
|
.tool-usage { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-usage summary { cursor: pointer; }
|
||||||
|
.tool-usage code { display: block; margin: 9px 0; font-family: var(--mono); font-size: 9px; overflow-wrap: anywhere; }
|
||||||
|
.tool-job { margin-top: 22px; padding: 22px; }
|
||||||
|
.tool-job .section-heading { flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.tool-job .select-wrap { max-width: 100%; }
|
||||||
|
.tool-job select { max-width: 100%; }
|
||||||
|
.tool-job-message { color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
|
||||||
|
.tool-progress { display: block; width: 100%; height: 5px; margin: 15px 0; border: none; border-radius: 10px; background: #e7e2eb; accent-color: #b8ad69; }
|
||||||
|
.tool-progress::-webkit-progress-bar { background: #e7e2eb; border-radius: 10px; }
|
||||||
|
.tool-progress::-webkit-progress-value { background: #c3b875; border-radius: 10px; }
|
||||||
|
.tool-progress::-moz-progress-bar { background: #c3b875; border-radius: 10px; }
|
||||||
|
.tool-job-items { padding: 0; margin: 0; list-style: none; }
|
||||||
|
.tool-job-items > li { padding: 10px 0; border-top: 1px solid var(--line); }
|
||||||
|
.tool-job-items summary { cursor: pointer; font-size: 11px; }
|
||||||
|
.tool-job-items summary > .tool-state { float: right; margin-left: 12px; }
|
||||||
|
.tool-job-items .form-error { margin-top: 10px; }
|
||||||
|
.tool-output { max-height: 240px; overflow: auto; margin: 10px 0 0; padding: 15px; border-radius: 12px; background: #34313c; color: #ede8f1; white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/1.7 var(--mono); }
|
||||||
|
.tools-recovery { margin-top: 16px; }
|
||||||
|
@keyframes tool-pulse { 50% { opacity: .35; } }
|
||||||
|
@media (max-width: 1250px) { .tools-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.tools-section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.tools-section-heading .button-row { justify-content: flex-start; }
|
||||||
|
.tools-grid { grid-template-columns: 1fr; }
|
||||||
|
.tool-card { padding: 16px; }
|
||||||
|
.tool-job { padding: 16px; }
|
||||||
|
.tool-choice { padding: 10px; gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tool-state-active::before { animation: none; } }
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
|
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
|
||||||
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
|
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
|
||||||
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=2" />
|
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=2" />
|
||||||
|
<link rel="stylesheet" href="/dashboard/static/css/tools.css?v=1" />
|
||||||
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
|
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
|
||||||
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
|
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
|
||||||
<script src="/dashboard/static/js/sessions.js?v=3" defer></script>
|
<script src="/dashboard/static/js/sessions.js?v=3" defer></script>
|
||||||
<script src="/dashboard/static/js/dashboard.js?v=5" defer></script>
|
<script src="/dashboard/static/js/tools.js?v=1" defer></script>
|
||||||
|
<script src="/dashboard/static/js/dashboard.js?v=6" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
@@ -178,6 +180,7 @@
|
|||||||
<div id="detail-error" class="notice notice-error" hidden></div>
|
<div id="detail-error" class="notice notice-error" hidden></div>
|
||||||
<nav class="machine-tabs" aria-label="Selected machine views">
|
<nav class="machine-tabs" aria-label="Selected machine views">
|
||||||
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
|
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
|
||||||
|
<a id="vm-tools-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-download"/></svg>Tools</a>
|
||||||
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
|
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="vm-overview-panel">
|
<div id="vm-overview-panel">
|
||||||
@@ -197,6 +200,19 @@
|
|||||||
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
||||||
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<section id="vm-tools-panel" aria-label="Selected machine tools" hidden>
|
||||||
|
<header class="section-heading tools-section-heading"><div><h2>Tools for your next idea</h2><span>One click. Ready inside this machine.</span></div><div class="button-row"><span id="tools-selected-count" class="muted">0 selected</span><button id="tools-install-selected" class="button button-primary" type="button" disabled>Install selected</button><button id="tools-refresh" class="icon-button" type="button" aria-label="Check installed tools" title="Check installed tools"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header>
|
||||||
|
<p id="tools-note" class="panel-footnote tools-note" role="status"></p>
|
||||||
|
<p id="tools-error" class="form-error tools-error" role="alert"></p>
|
||||||
|
<div id="tools-list" class="tools-grid"></div>
|
||||||
|
<article id="tools-job" class="soft-panel tool-job" hidden>
|
||||||
|
<header class="section-heading"><h3 id="tools-job-title">Installation</h3><label class="select-wrap"><span class="sr-only">Installation history</span><select id="tools-job-select"></select></label></header>
|
||||||
|
<p id="tools-job-message" class="tool-job-message" role="status"></p>
|
||||||
|
<progress id="tools-progress" class="tool-progress" max="1" value="0" aria-label="Installation progress"></progress>
|
||||||
|
<ol id="tools-job-items" class="tool-job-items"></ol>
|
||||||
|
</article>
|
||||||
|
<div class="button-row tools-recovery"><button id="tools-retry" class="button button-small" type="button" hidden>Retry unfinished tools</button><button id="tools-restart" class="button button-small" type="button" hidden>Restart & install</button></div>
|
||||||
|
</section>
|
||||||
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
|
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
|
||||||
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
|
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
|
||||||
<p id="sessions-error" class="form-error" role="alert"></p>
|
<p id="sessions-error" class="form-error" role="alert"></p>
|
||||||
@@ -229,7 +245,8 @@
|
|||||||
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
||||||
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section>
|
||||||
|
<section class="soft-panel form-section create-tools-section"><header class="section-heading"><div><h2><span class="step-number">04</span>Pick your tools</h2><span>Optional. Install automatically after your machine boots.</span></div><button id="create-tools-refresh" class="icon-button" type="button" aria-label="Reload tool catalog"><svg class="icon"><use href="#i-refresh"/></svg></button></header><div id="create-tools-list" class="tool-choices"><span class="muted">Loading available tools…</span></div><p id="create-tools-error" class="form-error" role="alert"></p><p class="panel-footnote">Python versions can coexist. You can add more tools later from the machine’s Tools tab.</p></section></div>
|
||||||
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@
|
|||||||
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
||||||
esc, formatDate, toast, confirmAction,
|
esc, formatDate, toast, confirmAction,
|
||||||
});
|
});
|
||||||
|
const tools = new window.NestTools({
|
||||||
|
api, esc, toast, formatDate,
|
||||||
|
onSelectionChange: updateCreateSummary,
|
||||||
|
onMutation: refreshAfterMutation,
|
||||||
|
});
|
||||||
|
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||||
@@ -170,7 +175,7 @@
|
|||||||
return `The server returned HTTP ${status}.`;
|
return `The server returned HTTP ${status}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, { method = 'GET', body } = {}) {
|
async function api(path, { method = 'GET', body, timeout = 20000 } = {}) {
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
state.controllers.add(controller);
|
state.controllers.add(controller);
|
||||||
@@ -179,7 +184,7 @@
|
|||||||
const timer = method === 'GET' ? window.setTimeout(() => {
|
const timer = method === 'GET' ? window.setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, 20000) : null;
|
}, timeout) : null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
@@ -612,17 +617,21 @@
|
|||||||
: actionButton('start', 'Start machine', 'play', true))
|
: actionButton('start', 'Start machine', 'play', true))
|
||||||
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
||||||
const showingSessions = state.detailTab === 'sessions';
|
const showingSessions = state.detailTab === 'sessions';
|
||||||
$('#vm-overview-panel').hidden = showingSessions;
|
const showingTools = state.detailTab === 'tools';
|
||||||
|
$('#vm-overview-panel').hidden = showingSessions || showingTools;
|
||||||
$('#vm-sessions-panel').hidden = !showingSessions;
|
$('#vm-sessions-panel').hidden = !showingSessions;
|
||||||
|
$('#vm-tools-panel').hidden = !showingTools;
|
||||||
$('#vm-overview-tab').href = vmHref(vm.id);
|
$('#vm-overview-tab').href = vmHref(vm.id);
|
||||||
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
||||||
[['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => {
|
$('#vm-tools-tab').href = `${vmHref(vm.id)}/tools`;
|
||||||
|
[['#vm-overview-tab', !showingSessions && !showingTools], ['#vm-sessions-tab', showingSessions], ['#vm-tools-tab', showingTools]].forEach(([selector, active]) => {
|
||||||
$(selector).classList.toggle('active', active);
|
$(selector).classList.toggle('active', active);
|
||||||
if (active) $(selector).setAttribute('aria-current', 'page');
|
if (active) $(selector).setAttribute('aria-current', 'page');
|
||||||
else $(selector).removeAttribute('aria-current');
|
else $(selector).removeAttribute('aria-current');
|
||||||
});
|
});
|
||||||
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`);
|
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : showingTools ? ' / Tools' : ''}`);
|
||||||
sessions.sync(vm, state.terminalId, showingSessions);
|
sessions.sync(vm, state.terminalId, showingSessions);
|
||||||
|
tools.sync(vm, showingTools);
|
||||||
html('#detail-config', detailRows([
|
html('#detail-config', detailRows([
|
||||||
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
||||||
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
||||||
@@ -685,6 +694,7 @@
|
|||||||
$('#detail-error').hidden = false;
|
$('#detail-error').hidden = false;
|
||||||
} else {
|
} else {
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
$('#detail-content').hidden = true;
|
$('#detail-content').hidden = true;
|
||||||
$('#detail-loading').hidden = false;
|
$('#detail-loading').hidden = false;
|
||||||
@@ -927,6 +937,7 @@
|
|||||||
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
|
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
|
||||||
['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
|
['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
|
||||||
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
|
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
|
||||||
|
['Tools', tools.createSummary()],
|
||||||
]));
|
]));
|
||||||
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
||||||
let mib = null;
|
let mib = null;
|
||||||
@@ -966,6 +977,7 @@
|
|||||||
password: $('#create-password').value,
|
password: $('#create-password').value,
|
||||||
ports: parsePorts($('#create-ports').value),
|
ports: parsePorts($('#create-ports').value),
|
||||||
no_default_ports: !$('#create-default-ssh').checked,
|
no_default_ports: !$('#create-default-ssh').checked,
|
||||||
|
tools: tools.createSelections(),
|
||||||
};
|
};
|
||||||
if (!payload.password) throw new Error('Add a password for the guest account.');
|
if (!payload.password) throw new Error('Add a password for the guest account.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -977,22 +989,25 @@
|
|||||||
const previous = button.innerHTML;
|
const previous = button.innerHTML;
|
||||||
const unlock = lockForm($('#create-form'));
|
const unlock = lockForm($('#create-form'));
|
||||||
state.busy.add('create');
|
state.busy.add('create');
|
||||||
|
tools.setCreateBusy(true);
|
||||||
button.textContent = 'Creating your machine…';
|
button.textContent = 'Creating your machine…';
|
||||||
try {
|
try {
|
||||||
const vm = await api('/vms', { method: 'POST', body: payload });
|
const vm = await api('/vms', { method: 'POST', body: payload });
|
||||||
if (session !== state.session) return;
|
if (session !== state.session) return;
|
||||||
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
|
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
|
||||||
$('#create-form').reset();
|
$('#create-form').reset();
|
||||||
|
tools.resetCreate();
|
||||||
resetPasswordVisibility();
|
resetPasswordVisibility();
|
||||||
updateCreateSummary();
|
updateCreateSummary();
|
||||||
toast('A new little machine, ready for you.');
|
toast(payload.tools.length ? 'Machine created. Your selected tools are queued for installation.' : 'A new little machine, ready for you.');
|
||||||
location.hash = vmHref(vm.id);
|
location.hash = vmHref(vm.id) + (payload.tools.length ? '/tools' : '');
|
||||||
await loadVms();
|
await loadVms();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
|
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
button.innerHTML = previous;
|
button.innerHTML = previous;
|
||||||
|
tools.setCreateBusy(false);
|
||||||
if (session === state.session) state.busy.delete('create');
|
if (session === state.session) state.busy.delete('create');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1166,8 @@
|
|||||||
if (parts[2] === 'sessions' && parts.length <= 4) {
|
if (parts[2] === 'sessions' && parts.length <= 4) {
|
||||||
detailTab = 'sessions';
|
detailTab = 'sessions';
|
||||||
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
||||||
|
} else if (parts[2] === 'tools' && parts.length === 3) {
|
||||||
|
detailTab = 'tools';
|
||||||
} else if (parts.length !== 2) identifier = null;
|
} else if (parts.length !== 2) identifier = null;
|
||||||
} catch { identifier = null; }
|
} catch { identifier = null; }
|
||||||
value = identifier ? 'vm-detail' : 'overview';
|
value = identifier ? 'vm-detail' : 'overview';
|
||||||
@@ -1164,6 +1181,7 @@
|
|||||||
state.detailTab = detailTab;
|
state.detailTab = detailTab;
|
||||||
state.terminalId = terminalId;
|
state.terminalId = terminalId;
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
state.portEditing = false;
|
state.portEditing = false;
|
||||||
state.resizeEditing = false;
|
state.resizeEditing = false;
|
||||||
@@ -1192,7 +1210,7 @@
|
|||||||
loadDetail(identifier);
|
loadDetail(identifier);
|
||||||
}
|
}
|
||||||
if (value === 'system') loadDiagnostics();
|
if (value === 'system') loadDiagnostics();
|
||||||
if (value === 'create') updateCreateSummary();
|
if (value === 'create') { updateCreateSummary(); tools.mountCreate(); }
|
||||||
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
|
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1271,7 @@
|
|||||||
|
|
||||||
function logout(message = '') {
|
function logout(message = '') {
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.reset();
|
||||||
state.session += 1;
|
state.session += 1;
|
||||||
state.controllers.forEach((controller) => controller.abort());
|
state.controllers.forEach((controller) => controller.abort());
|
||||||
state.controllers.clear();
|
state.controllers.clear();
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const active = (job) => ['queued', 'starting', 'waiting', 'installing'].includes(job?.status);
|
||||||
|
const retryable = (item) => ['failed', 'interrupted', 'skipped'].includes(item.status);
|
||||||
|
const labels = { queued: 'Queued', starting: 'Starting machine', waiting: 'Waiting for guest', installing: 'Installing',
|
||||||
|
installed: 'Installed', already_installed: 'Already installed', succeeded: 'Complete', failed: 'Failed',
|
||||||
|
interrupted: 'Interrupted', skipped: 'Not attempted' };
|
||||||
|
|
||||||
|
window.NestTools = class NestTools {
|
||||||
|
constructor({ api, esc, toast, formatDate, onSelectionChange, onMutation }) {
|
||||||
|
Object.assign(this, { api, esc, toast, formatDate, onSelectionChange, onMutation });
|
||||||
|
this.catalog = null;
|
||||||
|
this.catalogPromise = null;
|
||||||
|
this.epoch = 0;
|
||||||
|
this.generation = 0;
|
||||||
|
this.createSelected = new Set();
|
||||||
|
this.selected = new Set();
|
||||||
|
this.cache = new WeakMap();
|
||||||
|
this.createBusy = false;
|
||||||
|
this.vm = null;
|
||||||
|
this.data = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.submitting = false;
|
||||||
|
this.revision = 0;
|
||||||
|
this.probed = false;
|
||||||
|
this.lastProbe = 0;
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
$('#create-tools-list').addEventListener('change', (event) => {
|
||||||
|
const input = event.target.closest('[data-create-tool]');
|
||||||
|
if (!input) return;
|
||||||
|
if (input.checked) this.createSelected.add(input.dataset.createTool);
|
||||||
|
else this.createSelected.delete(input.dataset.createTool);
|
||||||
|
input.closest('label').classList.toggle('is-selected', input.checked);
|
||||||
|
this.onSelectionChange();
|
||||||
|
});
|
||||||
|
$('#create-tools-refresh').addEventListener('click', () => this.mountCreate());
|
||||||
|
$('#tools-refresh').addEventListener('click', () => this.load(true));
|
||||||
|
$('#tools-install-selected').addEventListener('click', () => this.submit([...this.selected]));
|
||||||
|
$('#tools-retry').addEventListener('click', () => this.submit(this.retryKeys()));
|
||||||
|
$('#tools-restart').addEventListener('click', () => this.submit(this.selected.size ? [...this.selected] : this.retryKeys(), true));
|
||||||
|
$('#tools-job-select').addEventListener('change', (event) => this.viewJob(event.target.value));
|
||||||
|
$('#tools-list').addEventListener('change', (event) => {
|
||||||
|
const input = event.target.closest('[data-tool-select]');
|
||||||
|
if (!input) return;
|
||||||
|
if (input.checked) this.selected.add(input.dataset.toolSelect);
|
||||||
|
else this.selected.delete(input.dataset.toolSelect);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
$('#tools-list').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-install-tool]');
|
||||||
|
if (button && !button.disabled) this.submit([button.dataset.installTool]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html(selector, value) {
|
||||||
|
const element = $(selector);
|
||||||
|
if (this.cache.get(element) !== value) {
|
||||||
|
element.innerHTML = value;
|
||||||
|
this.cache.set(element, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variants() {
|
||||||
|
return (this.catalog || []).flatMap((tool) => (tool.versions.length ? tool.versions : [null]).map((version) => ({
|
||||||
|
...tool, version, key: version ? `${tool.id}-${version}` : tool.id,
|
||||||
|
title: version ? `${tool.name} ${version}` : tool.name,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCatalog() {
|
||||||
|
if (this.catalog) return this.catalog;
|
||||||
|
if (this.catalogPromise) return this.catalogPromise;
|
||||||
|
const epoch = this.epoch;
|
||||||
|
const request = this.api('/tools').then((catalog) => {
|
||||||
|
if (!Array.isArray(catalog) || catalog.some((tool) => !tool.id || !Array.isArray(tool.versions))) {
|
||||||
|
throw new Error('The tool catalog response was not recognized.');
|
||||||
|
}
|
||||||
|
if (epoch === this.epoch) this.catalog = catalog;
|
||||||
|
return catalog;
|
||||||
|
}).finally(() => { if (this.catalogPromise === request) this.catalogPromise = null; });
|
||||||
|
this.catalogPromise = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountCreate() {
|
||||||
|
const epoch = this.epoch;
|
||||||
|
$('#create-tools-error').textContent = '';
|
||||||
|
try {
|
||||||
|
await this.loadCatalog();
|
||||||
|
if (epoch !== this.epoch) return;
|
||||||
|
this.html('#create-tools-list', this.variants().map((tool) => `<label class="tool-choice${this.createSelected.has(tool.key) ? ' is-selected' : ''}">
|
||||||
|
<input type="checkbox" data-create-tool="${this.esc(tool.key)}" ${this.createSelected.has(tool.key) ? 'checked' : ''} ${this.createBusy ? 'disabled' : ''} />
|
||||||
|
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? 'pip + venv' : tool.version_label)}</small></span></label>`).join(''));
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch === this.epoch && error.name !== 'AbortError') $('#create-tools-error').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSelections() {
|
||||||
|
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map(({ id, version }) => ({ id, version }));
|
||||||
|
}
|
||||||
|
|
||||||
|
createSummary() {
|
||||||
|
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map((tool) => tool.title).join(', ') || 'None selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCreate() {
|
||||||
|
this.createSelected.clear();
|
||||||
|
document.querySelectorAll('[data-create-tool]').forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
input.closest('label').classList.remove('is-selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateBusy(busy) {
|
||||||
|
this.createBusy = busy;
|
||||||
|
document.querySelectorAll('[data-create-tool]').forEach((input) => { input.disabled = busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(vm, visible) {
|
||||||
|
if (!visible) return;
|
||||||
|
if (this.vm?.id !== vm.id) {
|
||||||
|
this.leave();
|
||||||
|
this.vm = vm;
|
||||||
|
this.html('#tools-list', '<div class="loading-state">Bringing your tools into view…</div>');
|
||||||
|
$('#tools-job').hidden = true;
|
||||||
|
$('#tools-error').textContent = '';
|
||||||
|
this.load();
|
||||||
|
} else {
|
||||||
|
const started = this.vm.observed_status !== 'running' && vm.observed_status === 'running';
|
||||||
|
this.vm = vm;
|
||||||
|
this.render();
|
||||||
|
if (started && !this.loading) this.load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leave() {
|
||||||
|
this.generation += 1;
|
||||||
|
window.clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.vm = null;
|
||||||
|
this.data = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.submitting = false;
|
||||||
|
this.probed = false;
|
||||||
|
this.selected.clear();
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.leave();
|
||||||
|
this.epoch += 1;
|
||||||
|
this.catalog = null;
|
||||||
|
this.catalogPromise = null;
|
||||||
|
this.resetCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
window.clearTimeout(this.timer);
|
||||||
|
if (!this.vm) return;
|
||||||
|
this.timer = window.setTimeout(() => {
|
||||||
|
if (document.hidden) { this.schedule(); return; }
|
||||||
|
this.load(Boolean(this.data?.guest_busy && Date.now() - this.lastProbe > 20000));
|
||||||
|
}, active(this.data?.latest_job) ? 1500 : 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(refresh = false) {
|
||||||
|
if (!this.vm || this.loading) return;
|
||||||
|
const id = this.vm.id;
|
||||||
|
const generation = this.generation;
|
||||||
|
const revision = this.revision;
|
||||||
|
const wasActive = active(this.data?.latest_job);
|
||||||
|
this.loading = true;
|
||||||
|
if (refresh) { this.probed = true; this.lastProbe = Date.now(); }
|
||||||
|
$('#tools-refresh').disabled = true;
|
||||||
|
if (refresh) $('#tools-note').textContent = 'Checking installed tools inside this machine…';
|
||||||
|
let probeNext = false;
|
||||||
|
try {
|
||||||
|
const [, data] = await Promise.all([this.loadCatalog(), this.api(`/vms/${encodeURIComponent(id)}/tools${refresh ? '?refresh=true' : ''}`, { timeout: 100000 })]);
|
||||||
|
if (generation !== this.generation || revision !== this.revision) return;
|
||||||
|
this.data = data;
|
||||||
|
if (!data?.vm_id || data.vm_id !== id) throw new Error('The tool status response was not recognized.');
|
||||||
|
if (this.viewing && !data.jobs.some((job) => job.id === this.viewing)) { this.viewing = null; this.historyJob = null; }
|
||||||
|
this.selected = new Set([...this.selected].filter((key) => !data.inventory.some((item) => item.key === key && item.installed)));
|
||||||
|
$('#tools-error').textContent = data.error || '';
|
||||||
|
probeNext = !active(data.latest_job) && this.vm.observed_status === 'running'
|
||||||
|
&& ((wasActive && !refresh) || (!this.probed && data.stale));
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.loading = false;
|
||||||
|
$('#tools-refresh').disabled = false;
|
||||||
|
if (probeNext) this.load(true);
|
||||||
|
else this.schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retryKeys() {
|
||||||
|
return (this.data?.latest_job?.items || []).filter(retryable).map((item) => item.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
canInstall() {
|
||||||
|
const status = this.vm?.observed_status || this.vm?.status;
|
||||||
|
return ['running', 'stopped'].includes(status) && !this.vm.operation && !this.vm.pending_resize
|
||||||
|
&& (status !== 'stopped' || this.vm.ports !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.vm || !this.catalog) return;
|
||||||
|
const job = this.data?.latest_job;
|
||||||
|
const occupied = this.submitting || active(job) || this.data?.guest_busy;
|
||||||
|
const disabled = occupied || !this.canInstall();
|
||||||
|
const stopped = (this.vm.observed_status || this.vm.status) === 'stopped';
|
||||||
|
const focused = document.activeElement?.dataset.toolSelect;
|
||||||
|
const inventory = new Map((this.data?.inventory || []).map((item) => [item.key, item]));
|
||||||
|
const items = new Map((job?.items || []).map((item) => [item.key, item]));
|
||||||
|
this.html('#tools-list', this.variants().map((tool) => {
|
||||||
|
const found = inventory.get(tool.key);
|
||||||
|
const item = items.get(tool.key);
|
||||||
|
const installing = active(job) && item && ['queued', 'installing'].includes(item.status);
|
||||||
|
const installed = found?.installed && !installing;
|
||||||
|
const failed = item && retryable(item) && !installed && !installing;
|
||||||
|
const state = installing ? labels[item.status] : installed ? `Installed · ${found.detected_version}` : failed ? labels[item.status]
|
||||||
|
: found?.detected_version ? `Found ${found.detected_version}` : found ? 'Available' : 'Not checked yet';
|
||||||
|
const caption = stopped ? 'Start & install' : failed ? 'Retry' : 'Install';
|
||||||
|
return `<article class="tool-card${installed ? ' tool-installed' : ''}">
|
||||||
|
<label class="tool-card-heading"><input type="checkbox" data-tool-select="${this.esc(tool.key)}" aria-label="Select ${this.esc(tool.title)}" ${this.selected.has(tool.key) ? 'checked' : ''} ${disabled || installed ? 'disabled' : ''} />
|
||||||
|
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? `Python ${tool.version} · pip + venv` : tool.version_label)}</small></span></label>
|
||||||
|
<p>${this.esc(tool.description)}</p><small class="tool-source">${this.esc(tool.source)}</small>
|
||||||
|
<div class="tool-card-footer"><span class="tool-state ${installed ? 'tool-state-good' : failed ? 'tool-state-error' : installing ? 'tool-state-active' : ''}">${this.esc(state)}</span>
|
||||||
|
<button class="button button-small" type="button" data-install-tool="${this.esc(tool.key)}" ${disabled || installed ? 'disabled' : ''}>${installed ? 'Installed' : this.esc(caption)}</button></div>
|
||||||
|
${installed && found.usage ? `<details class="tool-usage"><summary>Get started</summary><code>${this.esc(found.usage)}</code><small>Open a new session for shell activation commands.</small></details>` : ''}</article>`;
|
||||||
|
}).join(''));
|
||||||
|
if (focused) document.querySelector(`[data-tool-select="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true });
|
||||||
|
$('#tools-selected-count').textContent = `${this.selected.size} selected`;
|
||||||
|
$('#tools-install-selected').textContent = this.submitting ? 'Queuing…' : stopped ? 'Start & install selected' : 'Install selected';
|
||||||
|
$('#tools-install-selected').disabled = disabled || !this.selected.size;
|
||||||
|
$('#tools-retry').hidden = !this.retryKeys().length || active(job);
|
||||||
|
$('#tools-retry').disabled = disabled;
|
||||||
|
$('#tools-restart').hidden = stopped || !this.data?.error || this.data.agent_ready || active(job);
|
||||||
|
$('#tools-restart').disabled = disabled || (!this.selected.size && !this.retryKeys().length);
|
||||||
|
$('#tools-note').textContent = this.data?.guest_busy
|
||||||
|
? 'An installation is still running inside the guest. Its status will be checked again shortly.'
|
||||||
|
: stopped ? 'Start & install boots this machine and installs your selected tools. Versions shown are last observed.'
|
||||||
|
: this.data?.checked_at ? `Last checked ${this.formatDate(this.data.checked_at)}${this.data.stale ? ' · cached observations' : ''}. Open a new session after installing to load shell integration.`
|
||||||
|
: 'Tools are installed directly inside this VM. Guest internet access is required for downloads.';
|
||||||
|
this.renderJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJob() {
|
||||||
|
const job = this.historyJob || this.data?.latest_job;
|
||||||
|
$('#tools-job').hidden = !job;
|
||||||
|
if (!job) return;
|
||||||
|
$('#tools-job-title').textContent = labels[job.status] || job.status;
|
||||||
|
$('#tools-job-message').textContent = job.error || (active(job) ? 'You can leave this page. Installation continues in the background.' : 'Tool installation results are saved with this machine.');
|
||||||
|
const complete = job.items.filter((item) => !['queued', 'installing'].includes(item.status)).length;
|
||||||
|
$('#tools-progress').max = job.items.length;
|
||||||
|
$('#tools-progress').value = complete;
|
||||||
|
$('#tools-progress').setAttribute('aria-label', `${complete} of ${job.items.length} tools processed`);
|
||||||
|
const names = new Map(this.variants().map((tool) => [tool.key, tool.title]));
|
||||||
|
const open = new Set([...document.querySelectorAll('#tools-job-items details[open]')].map((item) => item.dataset.output));
|
||||||
|
this.html('#tools-job-items', job.items.map((item) => `<li><details data-output="${this.esc(item.key)}" ${open.has(item.key) ? 'open' : ''}>
|
||||||
|
<summary><strong>${this.esc(names.get(item.key) || item.key)}</strong><span class="tool-state ${retryable(item) ? 'tool-state-error' : ''}">${this.esc(labels[item.status] || item.status)}</span></summary>
|
||||||
|
${item.error ? `<p class="form-error">${this.esc(item.error)}</p>` : ''}<pre class="tool-output" tabindex="0">${this.esc(item.output || 'No output recorded.')}</pre>
|
||||||
|
${item.truncated ? '<p class="panel-footnote">Showing the bounded tail of installation output.</p>' : ''}</details></li>`).join(''));
|
||||||
|
this.html('#tools-job-select', (this.data.jobs || []).map((item) => `<option value="${this.esc(item.id)}">${this.esc(this.formatDate(item.created_at))} · ${this.esc(labels[item.status] || item.status)}</option>`).join(''));
|
||||||
|
$('#tools-job-select').value = job.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewJob(id) {
|
||||||
|
if (!this.vm) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
this.viewing = id === this.data?.latest_job?.id ? null : id;
|
||||||
|
this.historyJob = null;
|
||||||
|
if (!this.viewing) { this.renderJob(); return; }
|
||||||
|
try {
|
||||||
|
const job = await this.api(`/vms/${encodeURIComponent(this.vm.id)}/tools/jobs/${encodeURIComponent(id)}`);
|
||||||
|
if (generation !== this.generation || this.viewing !== id) return;
|
||||||
|
this.historyJob = job;
|
||||||
|
this.renderJob();
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') this.toast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(keys, restart = false) {
|
||||||
|
if (!this.vm || this.submitting || active(this.data?.latest_job) || !this.canInstall()) return;
|
||||||
|
const tools = this.variants().filter((tool) => keys.includes(tool.key)).map(({ id, version }) => ({ id, version }));
|
||||||
|
if (!tools.length) return;
|
||||||
|
const id = this.vm.id;
|
||||||
|
const generation = this.generation;
|
||||||
|
const payload = { tools, start_if_stopped: true, restart };
|
||||||
|
const signature = JSON.stringify(payload);
|
||||||
|
if (this.pendingRequest?.signature !== signature) this.pendingRequest = { signature, id: crypto.randomUUID() };
|
||||||
|
payload.request_id = this.pendingRequest.id;
|
||||||
|
this.submitting = true;
|
||||||
|
this.revision += 1;
|
||||||
|
$('#tools-error').textContent = '';
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
const job = await this.api(`/vms/${encodeURIComponent(id)}/tools/install`, { method: 'POST', body: payload });
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
this.selected.clear();
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
if (this.data) { this.data.latest_job = job; this.data.jobs = [{ id: job.id, status: job.status, created_at: job.created_at }, ...this.data.jobs.filter((item) => item.id !== job.id)]; }
|
||||||
|
this.toast('Tool installation queued. You can follow its progress here.');
|
||||||
|
this.onMutation(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.submitting = false;
|
||||||
|
this.render();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user