diff --git a/nest/dashboard/static/css/tools.css b/nest/dashboard/static/css/tools.css new file mode 100644 index 0000000..3beaa95 --- /dev/null +++ b/nest/dashboard/static/css/tools.css @@ -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; } } diff --git a/nest/dashboard/static/index.html b/nest/dashboard/static/index.html index 39c7730..ad904f5 100644 --- a/nest/dashboard/static/index.html +++ b/nest/dashboard/static/index.html @@ -12,10 +12,12 @@ + - + +
@@ -197,6 +200,19 @@

A window into the runtime

Recent Firecracker log entries.

Every machine has its own private disk.
+ diff --git a/nest/dashboard/static/js/dashboard.js b/nest/dashboard/static/js/dashboard.js index e818fa4..d85a7a2 100644 --- a/nest/dashboard/static/js/dashboard.js +++ b/nest/dashboard/static/js/dashboard.js @@ -53,6 +53,11 @@ onExpired: () => logout('Your token was not accepted. Connect again to continue.'), esc, formatDate, toast, confirmAction, }); + const tools = new window.NestTools({ + api, esc, toast, formatDate, + onSelectionChange: updateCreateSummary, + onMutation: refreshAfterMutation, + }); function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (character) => ({ @@ -170,7 +175,7 @@ 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 controller = new AbortController(); state.controllers.add(controller); @@ -179,7 +184,7 @@ const timer = method === 'GET' ? window.setTimeout(() => { timedOut = true; controller.abort(); - }, 20000) : null; + }, timeout) : null; try { const response = await fetch(path, { method, @@ -612,17 +617,21 @@ : actionButton('start', 'Start machine', 'play', true)) + ``); 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-tools-panel').hidden = !showingTools; $('#vm-overview-tab').href = vmHref(vm.id); $('#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); if (active) $(selector).setAttribute('aria-current', 'page'); 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); + tools.sync(vm, showingTools); html('#detail-config', detailRows([ ['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], @@ -685,6 +694,7 @@ $('#detail-error').hidden = false; } else { sessions.leave(); + tools.leave(); state.detail = null; $('#detail-content').hidden = true; $('#detail-loading').hidden = false; @@ -927,6 +937,7 @@ ['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'], ['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'], ['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports], + ['Tools', tools.createSummary()], ])); const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] }; let mib = null; @@ -966,6 +977,7 @@ password: $('#create-password').value, ports: parsePorts($('#create-ports').value), no_default_ports: !$('#create-default-ssh').checked, + tools: tools.createSelections(), }; if (!payload.password) throw new Error('Add a password for the guest account.'); } catch (error) { @@ -977,22 +989,25 @@ const previous = button.innerHTML; const unlock = lockForm($('#create-form')); state.busy.add('create'); + tools.setCreateBusy(true); button.textContent = 'Creating your machine…'; try { const vm = await api('/vms', { method: 'POST', body: payload }); if (session !== state.session) return; if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.'); $('#create-form').reset(); + tools.resetCreate(); resetPasswordVisibility(); updateCreateSummary(); - toast('A new little machine, ready for you.'); - location.hash = vmHref(vm.id); + 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) + (payload.tools.length ? '/tools' : ''); await loadVms(); } catch (error) { if (!isAborted(error) && session === state.session) text('#create-error', error.message); } finally { unlock(); button.innerHTML = previous; + tools.setCreateBusy(false); if (session === state.session) state.busy.delete('create'); } } @@ -1151,6 +1166,8 @@ if (parts[2] === 'sessions' && parts.length <= 4) { detailTab = 'sessions'; terminalId = parts[3] ? decodeURIComponent(parts[3]) : null; + } else if (parts[2] === 'tools' && parts.length === 3) { + detailTab = 'tools'; } else if (parts.length !== 2) identifier = null; } catch { identifier = null; } value = identifier ? 'vm-detail' : 'overview'; @@ -1164,6 +1181,7 @@ state.detailTab = detailTab; state.terminalId = terminalId; sessions.leave(); + tools.leave(); state.detail = null; state.portEditing = false; state.resizeEditing = false; @@ -1192,7 +1210,7 @@ loadDetail(identifier); } 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 }); } @@ -1253,6 +1271,7 @@ function logout(message = '') { sessions.leave(); + tools.reset(); state.session += 1; state.controllers.forEach((controller) => controller.abort()); state.controllers.clear(); diff --git a/nest/dashboard/static/js/tools.js b/nest/dashboard/static/js/tools.js new file mode 100644 index 0000000..d01edaf --- /dev/null +++ b/nest/dashboard/static/js/tools.js @@ -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) => ``).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', '
Bringing your tools into view…
'); + $('#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 `
+ +

${this.esc(tool.description)}

${this.esc(tool.source)} + + ${installed && found.usage ? `
Get started${this.esc(found.usage)}Open a new session for shell activation commands.
` : ''}
`; + }).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) => `
  • + ${this.esc(names.get(item.key) || item.key)}${this.esc(labels[item.status] || item.status)} + ${item.error ? `

    ${this.esc(item.error)}

    ` : ''}
    ${this.esc(item.output || 'No output recorded.')}
    + ${item.truncated ? '

    Showing the bounded tail of installation output.

    ' : ''}
  • `).join('')); + this.html('#tools-job-select', (this.data.jobs || []).map((item) => ``).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(); + } + } + } + }; +})();