Files
UmbraTrans/web/uadmin/nodes.html
umbra audio b0cdec63a1 The root-install instructions assumed an account the machine may not have
The hint said to run 'sudo visudo' as though the account running the command already had
sudo. On a machine set up the way this one was - a separate administrator, then a
daily-use account - the daily-use account is not in the sudo group, so that instruction
could not be followed by the person who needed it. Replaced with the two steps that
actually apply, in order:

1. The account running the command must be in the sudo group. Check with 'id -nG'; if it
   is not there, an administrator adds it with 'usermod -aG sudo <account>' and the
   account signs out and back in, because group membership is fixed at sign-in. This is
   the step that was missing, and it is the one that cannot be done by the person stuck.
2. Passwordless sudo, from an account that already has sudo: 'sudo visudo -f
   /etc/sudoers.d/umbra-node', add the NOPASSWD line, save. Optional - without it the
   command still works and simply prompts.

Written after a run stopped at a sudo password prompt with nothing on screen but 'Sorry,
try again.', which is sudo's own message and appears nowhere in this codebase, so the
failure looked like the system refusing rather than the shell asking.

Checked against 238: umbra is already in the sudo group, so only step 2 applies there.
2026-10-03 20:46:05 -07:00

257 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="UmbraTrans administrative console">
<title>Administration · UmbraTrans</title>
<link rel="icon" type="image/png" href="../assets/umbratrans-emblem.png">
<link rel="stylesheet" href="../assets/styles.css">
</head>
<body class="admin-page">
<header class="admin-topbar">
<div class="admin-layout">
<a class="brand-lockup" href="../index.html">
<img class="brand-image" src="../assets/umbratrans-emblem.png" alt="">
<span>UmbraTrans</span>
</a>
<div class="admin-context">
<strong id="admin-display-name">Checking session…</strong>
<small id="admin-identity">No session</small>
</div>
<a class="button button--ghost" href="../settings.html">Sign out</a>
</div>
</header>
<main class="admin-main">
<nav class="admin-nav" aria-label="Sections">
<a class="admin-nav-link" href="./index.html#dashboard">Console</a>
<a class="admin-nav-link admin-nav-link--active" href="#nodes" data-view-link="nodes"
aria-current="page"><span aria-hidden="true">◈</span>Nodes</a>
<a class="admin-nav-link" href="./index.html">Everything else</a>
<a class="admin-nav-link" href="../settings.html">Your account</a>
</nav>
<section class="admin-view" id="view-nodes" data-view="nodes">
<div class="admin-heading">
<div>
<p class="eyebrow">Compute</p>
<h1>Nodes</h1>
<p>
Machines that can be given work. A node is <em>online</em> because its agent
heartbeated, not because it was created — so a node you have just added will
sit at <strong>provisioning</strong> until it runs the bootstrap, and a node
whose agent has stopped will read <strong>offline</strong> about 45 seconds
after its last heartbeat even though the stored status still says online.
</p>
</div>
<button class="button button--primary" type="button" id="open-add-node">
Add a node
</button>
</div>
<!-- Shown only when the account can see no organizations at all. A node belongs to
exactly one, so there is nothing to add it to and the form below is disabled
rather than merely described. -->
<div class="admin-banners">
<p class="admin-banner admin-banner--warning" id="no-organizations" hidden>
<strong>There are no organizations yet.</strong>
<p>
A node belongs to exactly one organization, so there is nowhere to put this one
until an organization exists. An organization is the outermost boundary in the
system: everything else lives inside one.
</p>
<p>
<a class="button button--primary" href="./index.html#organizations">
Go to organizations and create one
</a>
<a class="button button--ghost" href="./index.html#organizations">
Open the organizations screen
</a>
</p>
</p>
</div>
<div class="admin-card" id="add-node-card" hidden>
<h2>Add a node</h2>
<p class="otp-panel-target">
This produces a one-line command and a six-digit code. Run the command on the
machine; it asks for the code, pairs with the controller, installs the agent
and registers it as a service.
</p>
<form id="add-node-form" novalidate>
<div class="form-field">
<label for="node-organization">Organization</label>
<select id="node-organization" name="organization_id" required
aria-describedby="node-organization-hint">
<option value="">Choose an organization…</option>
</select>
<small id="node-organization-hint" class="field-hint">
A node belongs to exactly one organization, so the node list and every
job it takes are scoped by it. A platform administrator has no
organization of their own and must choose one — that is why this is a
field rather than something inferred.
</small>
</div>
<div class="form-field">
<label for="node-name">Node name</label>
<input id="node-name" name="node_name" type="text" autocomplete="off"
maxlength="120" required
aria-describedby="node-name-hint">
<small id="node-name-hint" class="field-hint">
The name this machine will report. The pairing code is bound to it, so a
code that ends up in the wrong hands cannot pair a different machine.
Lowercase letters, digits and dashes read best on a terminal.
</small>
</div>
<div class="form-field">
<label for="node-locality">Where it is</label>
<input id="node-locality" name="locality" type="text" autocomplete="off"
maxlength="120" placeholder="192.168.123.238 or a name you will recognise">
<small class="field-hint">For you, not for the system. Helps when six nodes
report at once.</small>
</div>
<div class="form-field">
<label for="node-trust-domain">Trust domain</label>
<select id="node-trust-domain" name="trust_domain">
<option value="local">local — on my own network</option>
<option value="remote">remote — reached across a network I do not control</option>
<option value="cloud">cloud — a hosted machine</option>
</select>
<small class="field-hint">
Used to refuse work across a boundary you have said is a boundary.
</small>
</div>
<div class="form-field">
<label for="node-controller">Controller address</label>
<input id="node-controller" name="controller_url" type="text" autocomplete="off"
value="http://192.168.123.14:8442">
<small class="field-hint">
The address the node will reach us on, from the node's side. If the node is
on another machine, use an address <em>it</em> can resolve.
</small>
</div>
<div class="form-field">
<input id="node-as-root" name="as_root" type="checkbox">
<label for="node-as-root">
Expect to run as root on an exclusive machine
</label>
<small class="field-hint" id="root-access-hint">
Ticking this changes the command below to <code>curl … | sudo sh</code> and
installs to <code>/opt/umbra</code> as a system service running as its own
<code>umbra-node</code> account. Unticked, everything stays in your home
directory with a user service and no password is needed.
</small>
<ol class="root-access-steps">
<li>
<strong>The account running the command must be in the
<code>sudo</code> group.</strong> On a machine set up with a separate
administrator and then a daily-use account, the first account created is
the one that gets <code>sudo</code>; a later account does not. Check
with <code>id -nG</code>. If <code>sudo</code> is not listed, an
administrator adds it:
<code>sudo usermod -aG sudo <var>account</var></code>
then sign out and back in — group membership is fixed at sign-in.
</li>
<li>
<strong>To avoid typing a password for every node</strong>, grant
passwordless sudo for that one account. Run this
<em>from an account that already has sudo</em>:
<code>sudo visudo -f /etc/sudoers.d/umbra-node</code>
then add this line and save:
<code>umbra ALL=(ALL) NOPASSWD: ALL</code>
Replace <code>umbra</code> with whichever account will run the command.
Without this step the command still works — it will simply ask for a
password each time.
</li>
</ol>
</div>
<div class="modal-actions">
<button class="button button--primary" type="submit" id="add-node-submit">
Create the pairing code
</button>
<button class="button button--ghost" type="button" id="add-node-cancel">
Cancel
</button>
</div>
</form>
</div>
<div class="admin-card" id="pairing-result-card" hidden>
<h2>Run this on the machine</h2>
<p class="otp-panel-target" id="pairing-node-name-note"></p>
<div class="otp-panel">
<div class="otp-panel-head">
<span class="field-label" id="pairing-command-label">Command</span>
</div>
<p class="otp-panel-target">
Paste this into a terminal on the machine you are onboarding. It downloads the
bootstrap; read it if you want to — it is a script fetched over the network.
</p>
<!-- The value and its copy button sit side by side, reusing the one-time-code
row the provisioning dialog already uses. A command a person is going to
retype should not be something they have to select by hand. -->
<div class="otp-value-row">
<code class="otp-value" id="pairing-command"></code>
<button class="button button--ghost" type="button" id="pairing-copy-command">
Copy
</button>
</div>
</div>
<div class="otp-panel">
<div class="otp-panel-head">
<span class="field-label" id="pairing-code-label">Six-digit code</span>
</div>
<p class="otp-panel-target">
The bootstrap asks for this. It expires, and it is spent by the first machine
that uses it — so copy it before you log out of this page.
</p>
<div class="otp-value-row">
<code class="otp-value" id="pairing-code"></code>
<button class="button button--ghost" type="button" id="pairing-copy-code">
Copy
</button>
</div>
<p class="otp-hint" id="pairing-code-hint" role="status" aria-live="polite"></p>
</div>
<div class="admin-banners">
<p class="admin-banner admin-banner--warning" id="pairing-warning" hidden>
<strong>Write this down now.</strong>
<span id="pairing-warning-text"></span>
If you lose it, create a new one — a lost code is not a problem, but a code
that has expired is a wasted trip.
</p>
</div>
<div class="modal-actions">
<button class="button button--ghost" type="button" id="pairing-done">
Done — clear this from the page
</button>
</div>
</div>
<div class="admin-card">
<div id="node-list" class="admin-table-wrap" aria-live="polite"></div>
</div>
</section>
</main>
<!-- showToast() in api.js looks for id="toast". With a different id every message -
success and failure alike - is appended to nothing and returns silently, which
looks exactly like the button not working. -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="../assets/api.js" defer></script>
<script src="../assets/nodes.js" defer></script>
</body>
</html>