Files
UmbraTrans/web/assets/nodes.js
umbra audio 62372a9c55 Wider code display, copy button beside the value
Feedback on the add-node page: the command was hard to read and hard to copy.

Both the command and the six-digit code now use .otp-value-row / .otp-value - the
wide monospace value with its copy button beside it that the provisioning dialog
already uses. Reusing that rather than writing new CSS, because a second styling of
the same idea is how the two drift apart.

The command was a <textarea> carrying a class named `code-block` that exists nowhere
in the stylesheet - I had invented it - so it rendered as a default textarea rather
than the monospace block it was meant to be. It is now a <code>, which means it is
written with setText rather than .value; setting .value on a <code> silently does
nothing, which is the same class of silent failure as the missing toast element.

The standalone "Copy the command" and "Copy the code" buttons at the bottom are gone:
each value now has its own button next to it, so there is one obvious way to copy each
thing and no need to match a button to a field.

Done is now labelled to say what it does - "clear this from the page" - because the
code is only in the page's memory and the controller cannot produce it again.
2026-10-03 12:03:39 -07:00

489 lines
15 KiB
JavaScript

/*
* The nodes view of the administrative console: list them, add one.
*
* Rules this file follows deliberately, and which the tests assert:
*
* - The pairing code and the rendered script are shown once and then discarded.
* They are never written to storage, never logged, and never put in a URL. There is
* no "copy the code again" button because the controller cannot produce it again —
* only an Argon2id hash is kept. A second button would be a lie about what the
* server can do.
* - No value from the API is ever turned into markup. Everything goes in through
* `api.el` / `textContent`. A node name is operator-supplied and ends up in the
* page, so it is treated as untrusted text.
* - Every non-GET request goes through `api.request`, which attaches the CSRF token.
* Nothing here can turn that off.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
var el = api.el;
var setText = api.setText;
var query = api.query;
var clear = api.clear;
var ORGANIZATION_ID = ''; // set from the session; see boot()
function say(message, kind) {
api.showToast(message, kind || 'info');
}
/* ------------------------------------------------------------------ list */
function statusBadge(status, schedulable, reason) {
var badge = el('span', { class: 'status-badge status-badge--' + (status || 'unknown') });
setText(badge, status || 'unknown');
badge.title = reason || '';
if (status === 'online' && schedulable === false) {
var note = el('span', { class: 'status-badge__note' });
setText(note, 'not schedulable');
badge.appendChild(note);
}
return badge;
}
function deviceSummary(devices) {
if (!devices || !devices.length) {
return 'no devices reported';
}
return devices
.map(function (device) {
var label = device.label || device.kind;
if (device.vram_total_mb) {
return (
label +
' (' +
device.vram_free_mb +
'/' +
device.vram_total_mb +
' MiB VRAM)'
);
}
if (device.ram_total_mb) {
return label + ' (' + device.ram_free_mb + '/' + device.ram_total_mb + ' MiB RAM)';
}
return label;
})
.join(', ');
}
function lastSeenText(node) {
if (!node.last_heartbeat_at) {
return 'never';
}
var parsed = new Date(node.last_heartbeat_at);
if (isNaN(parsed.getTime())) {
return 'unknown';
}
var seconds = Math.round((Date.now() - parsed.getTime()) / 1000);
if (seconds < 0) {
return 'just now';
}
if (seconds < 60) {
return seconds + 's ago';
}
if (seconds < 3600) {
return Math.round(seconds / 60) + 'm ago';
}
if (seconds < 86400) {
return Math.round(seconds / 3600) + 'h ago';
}
return Math.round(seconds / 86400) + 'd ago';
}
function renderNodeRow(node) {
var row = el('tr');
var nameCell = el('td');
var name = el('strong');
setText(name, node.name);
nameCell.appendChild(name);
if (node.stored_status && node.stored_status !== node.status) {
var stale = el('small', { class: 'table-note table-note--warn' });
setText(stale, 'stored as ' + node.stored_status);
nameCell.appendChild(stale);
}
row.appendChild(nameCell);
var statusCell = el('td');
statusCell.appendChild(statusBadge(node.status, node.schedulable, node.status_reason));
row.appendChild(statusCell);
var localityCell = el('td');
setText(localityCell, node.locality || '—');
row.appendChild(localityCell);
var devicesCell = el('td');
setText(devicesCell, deviceSummary(node.devices));
row.appendChild(devicesCell);
var seenCell = el('td');
setText(seenCell, lastSeenText(node));
row.appendChild(seenCell);
var reasonCell = el('td', { class: 'table-note' });
setText(reasonCell, node.status_reason || '');
row.appendChild(reasonCell);
var actionsCell = el('td');
if (node.status === 'online' || node.status === 'provisioning') {
actionsCell.appendChild(
actionButton('Drain', function () {
setStatus(node, 'draining');
})
);
}
if (node.status === 'draining' || node.status === 'offline') {
actionsCell.appendChild(
actionButton('Return to service', function () {
setStatus(node, 'online');
})
);
}
if (node.status !== 'retired') {
actionsCell.appendChild(
actionButton('Retire', function () {
setStatus(node, 'retired');
})
);
}
row.appendChild(actionsCell);
return row;
}
function actionButton(label, handler) {
var button = el('button', { class: 'button button--quiet button--small' });
button.type = 'button';
setText(button, label);
button.addEventListener('click', handler);
return button;
}
function renderNodes(nodes) {
var container = query('#node-list');
if (!container) {
return;
}
clear(container);
if (!nodes.length) {
var empty = el('p', { class: 'empty-state' });
setText(empty, 'No nodes yet. Add one, then run the command it gives you on that machine.');
container.appendChild(empty);
return;
}
var table = el('table', { class: 'admin-table' });
var head = el('thead');
var headRow = el('tr');
['Node', 'Status', 'Where', 'Devices', 'Last seen', 'Why', ''].forEach(function (title) {
var cell = el('th');
setText(cell, title);
headRow.appendChild(cell);
});
head.appendChild(headRow);
table.appendChild(head);
var body = el('tbody');
nodes.forEach(function (node) {
body.appendChild(renderNodeRow(node));
});
table.appendChild(body);
container.appendChild(table);
}
function loadNodes() {
// Always names the organization. The compute routes refuse to infer one for a
// platform administrator, so a bare /api/compute/nodes is a 422 waiting to happen.
var path = '/api/compute/nodes';
if (ORGANIZATION_ID) {
path += '?organization_id=' + encodeURIComponent(ORGANIZATION_ID);
}
return api.request(path).then(
function (nodes) {
renderNodes(nodes);
},
function (error) {
say(api.describeError(error), 'error');
}
);
}
function setStatus(node, status) {
api.request('/api/compute/nodes/' + encodeURIComponent(node.id) + '/status', {
method: 'PATCH',
body: { status: status },
}).then(
function () {
say(node.name + ' is now ' + status + '.', 'success');
return loadNodes();
},
function (error) {
say(api.describeError(error), 'error');
}
);
}
/* ------------------------------------------------------------------ add */
function showPairing(result, code, expiresAt, nodeName) {
var card = query('#pairing-result-card');
if (!card) {
return;
}
var note = query('#pairing-node-name-note');
if (note) {
setText(note, 'For the machine you will call ' + nodeName + '.');
}
var commandField = query('#pairing-command');
if (commandField) {
// textContent, never innerHTML: this is text a person will paste and it contains a
// code. The element is a <code>, so .value does not exist on it - setting it would
// silently do nothing, which is the same failure as the missing toast element.
setText(commandField, result.command || '');
}
var codeField = query('#pairing-code');
if (codeField) {
setText(codeField, code);
}
var hint = query('#pairing-code-hint');
if (hint && expiresAt) {
var parsed = new Date(expiresAt);
if (!isNaN(parsed.getTime())) {
setText(
hint,
'Expires at ' +
parsed.toLocaleTimeString() +
'. Shown once — only a hash is kept, so it cannot be shown again.'
);
}
}
var warning = query('#pairing-warning');
if (warning) {
warning.hidden = false;
}
card.hidden = false;
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function onAddNode(event) {
event.preventDefault();
var submit = query('#add-node-submit');
var nameField = query('#node-name');
var name = String(nameField && nameField.value ? nameField.value : '').trim();
if (!name) {
say('A node needs a name.', 'error');
if (nameField) {
nameField.focus();
}
return;
}
var organizationField = query('#node-organization');
var organizationId = String(
organizationField && organizationField.value ? organizationField.value : ''
).trim();
if (!organizationId) {
say('Choose the organization this node belongs to.', 'error');
if (organizationField) {
organizationField.focus();
}
return;
}
var localityField = query('#node-locality');
var domainField = query('#node-trust-domain');
var controllerField = query('#node-controller');
var rootField = query('#node-as-root');
api.disable(submit, true);
// organization_id goes in the query string, not the body. The route declares it as
// a Query parameter, and a JSON field of the same name is silently ignored - which
// is how the first version looked correct and then failed with 422
// `organization_required` because the server never saw one.
var path =
'/api/compute/pairing-script?organization_id=' + encodeURIComponent(organizationId);
api.request(path, {
method: 'POST',
body: {
node_name: name,
locality: String(localityField && localityField.value ? localityField.value : '').trim() || null,
trust_domain: String(domainField && domainField.value ? domainField.value : 'local'),
controller_url: String(controllerField && controllerField.value ? controllerField.value : '').trim(),
as_root: Boolean(rootField && rootField.checked),
ttl_seconds: 1800,
},
}).then(
function (result) {
api.disable(submit, false);
// Read once, displayed once, and not kept. The response is the only place these
// exist: the controller stores an Argon2id hash of the code and cannot produce
// it again, which is why there is no "show the code" button below.
//
// Taken verbatim rather than recovered from the script text. An earlier version
// scraped the six digits out of `result.code` with a regex, which would have
// silently produced an empty code had the response shape ever changed - and a
// blank code looks exactly like a working page.
showPairing(result, String(result.code || ''), result.expires_at, name);
hideAddForm();
say('Pairing code created for ' + name + '.', 'success');
return loadNodes();
},
function (error) {
api.disable(submit, false);
say(api.describeError(error), 'error');
}
);
}
function hideAddForm() {
var card = query('#add-node-card');
if (card) {
card.hidden = true;
}
}
function showAddForm() {
var card = query('#add-node-card');
if (card) {
card.hidden = false;
var field = query('#node-name');
if (field) {
field.focus();
}
}
}
/* --------------------------------------------------------------- copying */
function copyText(text, what) {
if (!text) {
say('Nothing to copy.', 'error');
return;
}
var done = function () {
say(what + ' copied. Paste it somewhere you will find it.', 'success');
};
if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) {
global.navigator.clipboard.writeText(text).then(done, function () {
say('The browser would not copy it. Select the text and copy it by hand.', 'error');
});
return;
}
say('This browser will not copy automatically. Select the text and copy it by hand.', 'error');
}
/* ------------------------------------------------------------------ boot */
function boot() {
var openButton = query('#open-add-node');
if (openButton) {
openButton.addEventListener('click', showAddForm);
}
var cancelButton = query('#add-node-cancel');
if (cancelButton) {
cancelButton.addEventListener('click', hideAddForm);
}
var form = query('#add-node-form');
if (form) {
form.addEventListener('submit', onAddNode);
}
var doneButton = query('#pairing-done');
if (doneButton) {
doneButton.addEventListener('click', function () {
var card = query('#pairing-result-card');
if (card) {
card.hidden = true;
}
// The code is gone from the page for good. The controller keeps only a hash,
// so there is nothing to re-display and pretending otherwise would be a lie.
var codeField = query('#pairing-code');
if (codeField) {
setText(codeField, '');
}
var commandField = query('#pairing-command');
if (commandField) {
setText(commandField, '');
}
});
}
var copyCommand = query('#pairing-copy-command');
if (copyCommand) {
copyCommand.addEventListener('click', function () {
var field = query('#pairing-command');
copyText(field ? field.textContent : '', 'Command');
});
}
var copyCode = query('#pairing-copy-code');
if (copyCode) {
copyCode.addEventListener('click', function () {
var field = query('#pairing-code');
copyText(field ? field.textContent : '', 'Code');
});
}
loadOrganizations();
}
/*
* The organization a node belongs to.
*
* A platform administrator has no organization of their own, and the compute routes
* refuse to guess: they raise `organization_required` and ask for
* `?organization_id=…`. The first version read an organization_id out of
* /api/uadmin/summary, which does not have that field, so it was always empty and
* every create failed with a 422. So the organization is chosen here, explicitly.
*
* This is also the correct shape for an organization administrator: they have exactly
* one, and the picker shows it without making them choose.
*/
function loadOrganizations() {
return api.request('/api/uadmin/organizations').then(
function (data) {
var items = (data && data.items) || [];
var select = query('#node-organization');
if (!select) {
return;
}
clear(select);
var placeholder = el('option', { value: '', text: 'Choose an organization…' });
select.appendChild(placeholder);
items.forEach(function (org) {
select.appendChild(el('option', { value: org.id, text: org.name }));
});
if (items.length === 1) {
select.value = items[0].id;
ORGANIZATION_ID = items[0].id;
}
return loadNodes();
},
function (error) {
say(api.describeError(error), 'error');
}
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
global.UmbraNodes = { loadNodes: loadNodes };
})(window);