diff --git a/apps/ui/src/styles/app.css b/apps/ui/src/styles/app.css
index c33a801..3f7ad40 100644
--- a/apps/ui/src/styles/app.css
+++ b/apps/ui/src/styles/app.css
@@ -2318,3 +2318,29 @@ input[type='radio']:disabled {
:root[data-motion='none'] .refresh-icon.spin {
animation: none;
}
+
+/*
+ * The vitals strip — a control plane's instrument row, under the page title.
+ *
+ * Numbers, not sentences: it replaced three paragraphs explaining why restarts and OOM kills
+ * matter. Monospaced and tabular so the digits hold their column as they change on the poll, and
+ * COLOURLESS until something is wrong — a healthy control plane should read as one quiet grey line,
+ * which is what makes the red mean anything.
+ */
+.vitals {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s1) var(--s3);
+ margin-top: var(--s2);
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: var(--t-xs);
+ font-variant-numeric: tabular-nums;
+ color: var(--fg-mute);
+}
+.vitals > * + *::before {
+ content: '·';
+ margin-right: var(--s3);
+ color: var(--line);
+}
+.vitals .v-warn { color: var(--leak); }
+.vitals .v-fail { color: var(--fail); }
diff --git a/apps/ui/src/views/ConfigView.vue b/apps/ui/src/views/ConfigView.vue
index f9e23cd..f4357cf 100644
--- a/apps/ui/src/views/ConfigView.vue
+++ b/apps/ui/src/views/ConfigView.vue
@@ -105,12 +105,7 @@ async function run(mode: 'preview' | 'apply'): Promise {
Apply a configuration
-
- A sealed pstack pull config export from another host: accounts, API tokens,
- variables and secrets, notifiers, sign-on, registry logins, routing files and named specs.
- It creates what is missing and never overwrites, so applying one twice changes
- nothing the second time.
-
+
A sealed export from another host. Creates what is missing, never overwrites.
@@ -129,10 +124,7 @@ async function run(mode: 'preview' | 'apply'): Promise {
placeholder="the passphrase this file was sealed with"
@input="clearPreview"
/>
-
- Sent to this host so it can open the file. That is safe in a way exporting would not be:
- everything inside is about to be stored here in plain text regardless.
-
+ Sent to this host to open the file.
@@ -149,7 +141,7 @@ async function run(mode: 'preview' | 'apply'): Promise {
:disabled="!preview || !!busy"
@click="run('apply')"
/>
- Preview first — Apply stays disabled until you have seen what this file does.
+ Preview before applying.
diff --git a/apps/ui/src/views/ControlView.vue b/apps/ui/src/views/ControlView.vue
index 6baa810..1c3f396 100644
--- a/apps/ui/src/views/ControlView.vue
+++ b/apps/ui/src/views/ControlView.vue
@@ -19,8 +19,6 @@ import { ago, sentence, stamp } from '../composables/useFormat';
import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import HelpModal from '../components/HelpModal.vue';
-import InfoHint from '../components/InfoHint.vue';
import RefreshButton from '../components/RefreshButton.vue';
import SkeletonList from '../components/SkeletonList.vue';
@@ -49,7 +47,7 @@ async function restart(service: string): Promise {
toast('error', problem(r, `restart ${service}`));
return;
}
- toast('ok', `Restarting ${r.body.container} — back in a few seconds.`);
+ toast('ok', `Restarting ${r.body.container}.`);
await load();
}
@@ -81,7 +79,7 @@ async function saveDomains(next: string[]): Promise {
return;
}
newDomain.value = '';
- toast('ok', 'Saved — Traefik picks these up within a couple of seconds.');
+ toast('ok', 'Domains saved.');
await Promise.all([loadDomains(), loadTls()]);
}
@@ -96,6 +94,15 @@ async function loadTls(): Promise {
}
usePolling(loadTls, 30_000);
+// The two numbers that explain a control plane misbehaving, summed for the strip.
+const restarts = computed(() => (view.value?.containers ?? []).reduce((n, c) => n + c.restartCount, 0));
+const oomed = computed(() => (view.value?.containers ?? []).some((c) => c.oomKilled));
+// The primary is a hostname, not an entry in `domains` — but it is only there once init has set
+// one, so it is counted rather than assumed.
+const domainCount = computed(() =>
+ domains.value ? domains.value.domains.length + (domains.value.primary ? 1 : 0) : 0,
+);
+
const daysLeft = computed(() => {
if (!tls.value?.wildcard) return null;
return Math.floor((tls.value.wildcard.notAfter - Date.now()) / 86_400_000);
@@ -117,7 +124,7 @@ async function storeWildcard(): Promise {
}
certDraft.value = '';
keyDraft.value = '';
- toast('ok', 'Wildcard stored — new deploys inherit it now. Redeploy the rest below.');
+ toast('ok', 'Stored — redeploy all stacks.');
redeployed.value = null; // that summary described a run under the previous mode
await loadTls();
}
@@ -132,7 +139,7 @@ async function removeWildcard(): Promise {
toast('error', problem(r, 'remove the wildcard'));
return;
}
- toast('ok', 'Removed — back to Traefik-native resolution. Redeploy the stacks below.');
+ toast('ok', 'Removed — redeploy all stacks.');
redeployed.value = null;
await loadTls();
}
@@ -148,7 +155,7 @@ async function redeployAll(): Promise {
return;
}
redeployed.value = r.body;
- toast('ok', `Redeploying ${r.body.started.length} stack${r.body.started.length === 1 ? '' : 's'}; ${r.body.skipped.length} skipped.`);
+ toast('ok', `Redeploying ${r.body.started.length}, skipped ${r.body.skipped.length}.`);
}
@@ -156,34 +163,19 @@ async function redeployAll(): Promise {
-
- Control stack
-
-
- A restarting Traefik quietly breaks TLS issuance. Its certificate challenges
- live in process memory, so every restart abandons whatever was mid-flight — the failed
- attempts still count against Let's Encrypt's weekly limits, and every container on
- this page keeps reporting running. A climbing restart count with an
- OOM badge is that story told in two cells.
-
-
- Restart is the only action, and never for pstack. The server refuses to restart
- its own container — it is the process answering the request, and if its image were
- broken, the thing that could repair this host would die with it. From the host:
- docker compose -p pstack-control restart pstack.
-
-
- Restarting traefik drops every connection for a few seconds — every preview,
- this page included. The containers themselves keep running; only routing blinks.
-
-
-
-
- The machinery previews run behind, with the counters that catch it misbehaving
-
- docker inspect over the control project's containers, every ten seconds.
- Restart counts and OOM flags are docker's own, since this process last recreated nothing.
-
+
@@ -200,10 +192,9 @@ async function redeployAll(): Promise {
-
- Docker did not answer.
-
Nothing about the control stack is known right now — which is not the same as it being down.
-
+
+
Docker isn't answering. Nothing here is known.
@@ -232,7 +223,7 @@ async function redeployAll(): Promise {
OOM
@@ -252,7 +243,7 @@ async function redeployAll(): Promise {
v-else-if="c.service === 'pstack'"
class="mute"
style="font-size: var(--t-sm)"
- title="The server refuses to restart the container answering this request. Restart it from the host."
+ title="Restart this one from the host"
>
host-only
@@ -268,10 +259,7 @@ async function redeployAll(): Promise {
-
- Docker lists no control containers — on a host serving this page, that means the project
- label changed, not that nothing runs.
-
+
None listed — the project label changed, not the stack stopped.
{{ domains.primary || 'not set' }}
-
- primary — its routers are labels on the pstack container, so it keeps working whatever
- else changes here. It cannot be removed from this page.
-
+ primary
{{ d }}
@@ -317,11 +300,7 @@ async function redeployAll(): Promise {
Add
-
- Adding a domain only makes this host serve and wake names under it. To move a
- deployment, set PREVIEW_DOMAIN in its variables and redeploy it — one stack at a
- time, and rolling back is that same stack.
-
@@ -356,29 +333,21 @@ async function redeployAll(): Promise {
-
+
Remove wildcard
-
- Stacks deployed under it then have no certificate until redeployed.
-
+ Stacks deployed under it serve no certificate until redeployed.
-
-
- Renew or replace it. Paste the new pair — it overwrites both halves in place and
- Traefik picks it up immediately. Nothing needs redeploying for a renewal: the router
- labels do not change, only the certificate behind them.
-
-
- Bring your own wildcard. Paste a certificate for *.your-domain
- and its key — previews stop ordering per-hostname certificates the moment it lands, with no
- re-init and no Traefik restart.
-
- The key is stored 0600 and nothing ever returns it.
-
- Storing a wildcard is an admin's: it changes what every hostname on this host presents.
-
+
Admin only.
Redeploy all stacks
-
- Router labels are stamped at deploy time — run this once after changing the mode. Asleep
- stacks are skipped; they pick the new labels up when they wake.
-
+ Router labels are stamped at deploy time. Asleep stacks pick them up on wake.
{{ leakedJobs.length }} teardown(s) left something behind.
- Something survived being torn down, and nothing else is going to remove it.
-
- compose down -v removed the containers, volumes and networks — whatever is
- left is outside compose's reach.
-
+ Nothing else will remove it.
state.jobs.slice(0, 8));
Control stack
- pstack itself runs in this stack, so starting or stopping it here would kill the process
- handling the request — and a failed self-upgrade would leave the host with no control
- plane and no way back in. It is managed from the host instead.
- Managed by {{ control.managedBy }}.
+ pstack runs in this stack, so it is managed from the
+ host by {{ control.managedBy }}.
{{ control.project }}
@@ -116,24 +109,14 @@ const recentJobs = computed(() => state.jobs.slice(0, 8));
-->
Docker did not answer.
-
- Check Docker is running, then refresh.
-
- This is not the same as “nothing is running”: the control stack may be
- perfectly healthy while this process cannot reach the Docker socket.
-
-
+
State unknown — the stack may be running.
Docker's answer could not be read.
-
Service state is unknown for this refresh; the stack itself may be fine.
+
State unknown for this refresh.
Nothing is running in this project.
-
- Docker answered clearly, so the control stack really is down — yet you are reading a
- page it serves. You are most likely reaching it by some other route.
-
- {{ summary.unresolved }} deployment(s) are missing variables.
- Open one and set them.
+ {{ summary.unresolved }} deployment(s) need variables.
+ Set them →
- “Unknown” means it could not be determined
-
- — not that the deployment is idle. An unknown is never counted as a zero,
- which is how a live stack gets reported as torn down.
-
+ Unknown is not zero — state could not be determined.
- No jobs yet.
-
- History is held in memory, so restarting the server clears it. A job record is the
- transcript of an attempt, not the truth about what exists now.
-
-
+
No jobs yet.
diff --git a/apps/ui/src/views/JobDetailView.vue b/apps/ui/src/views/JobDetailView.vue
index 8e5e006..f9d0ebf 100644
--- a/apps/ui/src/views/JobDetailView.vue
+++ b/apps/ui/src/views/JobDetailView.vue
@@ -8,11 +8,11 @@
* to pick up `outcome`, which only exists after the job finishes.
*
* A QUEUED JOB HAS A STREAM TOO, and it is open and silent until the job dispatches. That is the
- * one case where "no events yet" is not a broken connection, so the page says what it is waiting
- * for instead of showing an empty log: the panel reads "waiting to start", and a banner above it
- * says WHY — behind its own stack, or behind the host's job cap. The first line to arrive IS the
- * dispatch, and nothing else would re-fetch, so it triggers one `load()`; without that the badge
- * would still read "Queued" while output scrolled past underneath it.
+ * one case where "no events yet" is not a broken connection, so the page says it is waiting instead
+ * of showing an empty log, and a banner above says which wait it is — behind its own stack, or
+ * behind the host's job cap. The first line to arrive IS the dispatch, and nothing else would
+ * re-fetch, so it triggers one `load()`; without that the badge would still read "Queued" while
+ * output scrolled past underneath it.
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { api, problem } from '../api/client';
@@ -223,11 +223,6 @@ onBeforeUnmount(closeStream);
variant="danger"
:pending="cancelling"
:confirm="queued ? 'Drop it? It never ran.' : 'Stop it? Nothing is undone.'"
- :title="
- queued
- ? 'It has not started, so there is nothing left behind to clean up.'
- : 'Kills the command in flight. Anything already created or destroyed stays that way.'
- "
@run="cancel"
>
{{ queued ? 'Drop' : 'Stop' }}
@@ -236,31 +231,25 @@ onBeforeUnmount(closeStream);
- Waiting to start — nothing has run yet.
+ Waiting to start.
+ Behind
{{
actionLabel(wait.blocker.action)
}}
- is already running on {{ job?.stack }}. One job runs per stack at a time — a teardown racing a deploy over the same database is
- the failure that rule exists to prevent — so this one starts when that one ends.
+ on {{ job?.stack }}.
- Nothing is running on {{ job?.stack }}, so the wait is host-wide: {{ wait.running }} jobs hold a slot across every stack, and the
- host runs at most PSTACK_MAX_JOBS at once (4 unless this server was told
- otherwise). This one starts as soon as a slot frees.
-
-
- It starts once its own stack is free and the host has a spare job slot.
+ No free job slot — {{ wait.running }} running host-wide.
-
Dropping it now costs nothing — it has not touched anything yet.
+
Waiting for its stack and a job slot.
- Superseded — this job never ran.
+ Superseded — never ran.
+ Replaced while queued by
- A newer
{{
actionLabel(replacement.action).toLowerCase()
}}
- for {{ job.stack }} replaced it while it was queued.
-
-
- A newer job for {{ job.stack }} replaced it while it was queued.
- The queue is one deep, so a burst of pushes runs the first deploy and then exactly one more
- carrying the newest spec. Nothing was created or destroyed here — there is no partial state
- to go looking for, and nothing to verify.
+ a newer job
+ for {{ job.stack }}.
@@ -292,10 +277,7 @@ onBeforeUnmount(closeStream);
-->
Stopped by {{ job.cancelledBy ?? 'an operator' }} — nothing was undone.
-
- Whatever this job had already created or destroyed is still that way. Run
- Verify on the deployment to see what actually exists.
-
- These axes' assert_gone failed, so the resources are still present. They will
- not be retried — tear them down by hand, then re-run verify.
-
+
Still present, not retried — remove by hand.
@@ -331,7 +310,7 @@ onBeforeUnmount(closeStream);
loading
? 'Loading…'
: queued
- ? 'Waiting to start — there is no output until it does.'
+ ? 'Waiting to start.'
: 'No output.'
"
>
@@ -346,9 +325,8 @@ onBeforeUnmount(closeStream);
Steps
-
- {{ unverifiable }} unverifiable — no assert_gone, so nothing was checked
-
+
+ {{ unverifiable }} unverifiable
diff --git a/apps/ui/src/views/LoginView.vue b/apps/ui/src/views/LoginView.vue
index 878e6ff..6166058 100644
--- a/apps/ui/src/views/LoginView.vue
+++ b/apps/ui/src/views/LoginView.vue
@@ -15,7 +15,6 @@ import { useRoute, useRouter } from 'vue-router';
import { authState, checkAuth, login } from '../composables/useAuth';
import { api } from '../api/client';
import ActionButton from '../components/ActionButton.vue';
-import InfoHint from '../components/InfoHint.vue';
import EquivalentCommand from '../components/EquivalentCommand.vue';
import SsoMark from '../components/SsoMark.vue';
@@ -83,34 +82,20 @@ async function submit(): Promise {
Sign in
-
- No accounts exist on this server yet — create the first one, then sign in.
-
- Create the first account
-
- This has to be done with the host token, from a terminal — a browser has no way to prove
- it is allowed to claim a fresh server.
-
+ No accounts yet — create the first one.
+
Needs the host token.
-
- It can also be set up ahead of time when the host is first provisioned.
-
- Set the admin username and password in the environment before running the setup
- command on the host. They are honoured only while no account exists, so they cannot
- overwrite one later.
-
-
@@ -138,13 +123,7 @@ async function submit(): Promise {
{{ pending ? 'Signing in…' : 'Sign in' }}
-
- Use a token instead
-
- A bearer token in Settings — the machine token from pstack init, or a
- personal token from your account — authenticates every request without a session.
-
-
+ Use a token instead
diff --git a/apps/ui/src/views/NotifiersView.vue b/apps/ui/src/views/NotifiersView.vue
index 5f37765..171d63c 100644
--- a/apps/ui/src/views/NotifiersView.vue
+++ b/apps/ui/src/views/NotifiersView.vue
@@ -18,7 +18,6 @@ import { sentence, stamp } from '../composables/useFormat';
import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import InfoHint from '../components/InfoHint.vue';
import SkeletonList from '../components/SkeletonList.vue';
import RelativeTime from '../components/RelativeTime.vue';
import SelectMenu from '../components/SelectMenu.vue';
@@ -148,7 +147,7 @@ async function test(n: NotifierRow): Promise {
const res = r.body.result;
// The endpoint's own answer, verbatim — "it failed" without the reason is the thing this page
// exists to avoid.
- if (res.ok) toast('ok', `${n.name} accepted the test delivery.`);
+ if (res.ok) toast('ok', 'Test delivered.');
else toast('error', `${n.name}: ${res.error ?? `HTTP ${res.status}`}`);
void load();
}
@@ -211,14 +210,7 @@ async function redeliver(d: DeliveryRow): Promise {
Notifiers
-
- Where events on this host get sent
-
- A registration that receives events — a teardown that leaked, a deployment created, a job
- that failed. Webhooks today; the server decides what other kinds exist, and this page
- renders whatever it offers.
-
-
+
Where events on this host get sent
@@ -228,8 +220,7 @@ async function redeliver(d: DeliveryRow): Promise {
- This server has no notifiers.
-
It is an older build of pstack. Upgrade the host to register webhooks.
- Signing secret for “{{ revealed.name }}” — copy it now.
-
- This is the only time it is shown — the server keeps no way to show it again. Your
- receiver uses it to check that a delivery really came from here.
-
- Each delivery carries a signature header computed from this secret and the exact body
- sent. Recompute it on your side and compare; a mismatch means the delivery was not sent
- by this host, or was altered on the way.
-
-
+ Signing secret for “{{ revealed.name }}” — shown once. Copy it now.
{{ revealed.secret }}
@@ -316,10 +298,6 @@ async function redeliver(d: DeliveryRow): Promise {
{{ queued }} queued
-
- Events wait their turn per notifier — one delivery at a time, so a slow
- receiver cannot starve the others.
-
@@ -352,7 +330,7 @@ async function redeliver(d: DeliveryRow): Promise {
variant="ghost"
:pending="redelivering === d.id"
confirm="Send it again?"
- title="Sends this exact event again, with its original id so a receiver that already handled it can dedupe."
+ title="Sends this event again with its original id, so a receiver can dedupe."
@run="redeliver(d)"
>
Redeliver
@@ -365,9 +343,7 @@ async function redeliver(d: DeliveryRow): Promise {
-
- Nothing registered. Events still happen — nobody is being told about them.
-
- Events
-
- All events also covers events added in later versions — a specific list does not,
- and the failure mode of that is silence nobody can explain. job.leaked is the
- one worth alerting on: a teardown ran and something survived it.
-
-
+
Events
+
- Deliveries are signed with a secret shown once at registration, retried twice on failure,
- and logged here.
-
- Recompute HMAC-SHA256(secret, `${timestamp}.${rawBody}`) and compare against
- X-Pstack-Signature, using the raw body — re-serialising it changes
- the bytes. Reject anything whose X-Pstack-Timestamp is more than five minutes
- old, and dedupe on X-Pstack-Delivery: delivery is at-least-once, and that id
- is stable across retries.
-
-
-
- The webhook URL is the credential — it is stored write-only and scrubbed from logs, and no
- signing secret is involved. Deliveries are retried twice on failure and logged here.
+ Signed with a secret shown once, at registration.
+
The webhook URL is the credential — stored write-only.
diff --git a/apps/ui/src/views/RegistriesView.vue b/apps/ui/src/views/RegistriesView.vue
index 407f178..8826ff3 100644
--- a/apps/ui/src/views/RegistriesView.vue
+++ b/apps/ui/src/views/RegistriesView.vue
@@ -24,9 +24,7 @@ import { settings } from '../composables/useSettings';
import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import InfoHint from '../components/InfoHint.vue';
import SkeletonList from '../components/SkeletonList.vue';
-import HelpModal from '../components/HelpModal.vue';
import RefreshButton from '../components/RefreshButton.vue';
const entries = ref([]);
@@ -77,14 +75,9 @@ async function save(): Promise {
// Cleared immediately: there is no reason for a password to stay in a form field after it is stored,
// and the page cannot read it back to re-populate one.
password.value = '';
- const stored = r.body.registry;
- if (stored && stored !== host.value) {
- // Docker Hub's canonical key differs from what anyone types; saying so avoids "I stored it and it
- // still is not used".
- toast('ok', `Stored for ${stored} — Docker Hub's canonical key.`);
- } else {
- toast('ok', `Stored for ${stored || host.value}. It applies to the next pull.`);
- }
+ // The server's key, not the typed one: Docker Hub canonicalises, and the list below reloads with
+ // whatever it stored — so the toast does not have to explain the difference.
+ toast('ok', `Stored for ${r.body.registry || host.value}.`);
host.value = '';
username.value = '';
void load();
@@ -106,15 +99,7 @@ async function forget(registry: string): Promise {
Registries
-
- Credentials for pulling private images
-
- A pull is authenticated by the docker client, not the daemon: it reads its own
- config.json and hands the credential over. pstack's client runs inside the
- control container, so a docker login on the host writes a file it cannot see —
- and the pull fails on a host that is logged in.
-
-
- Re-run setup on the host — it is safe to repeat.
-
-
- Saving a credential needs a writable directory that the control stack only started
- providing in version 0.7.0. A host set up before then does not have it, so this page can
- list credentials but not add one.
-
-
- Re-running setup is safe to repeat and recreates the control containers with the
- directory in place. Nothing already stored is lost.
-
-
-
+ Credentials cannot be saved here. Re-run setup on the host.
- This file uses a credential helper, which will not work here.
-
- Found: {{ helpers.join(', ') }}. A helper keeps the secret outside the
- file — in an OS keychain, usually — and the helper binary does not exist in this container, so
- pulls from private registries will fail. Add the credential below instead, which
- stores it in the file.
-
+
+ Credential helper {{ helpers.join(', ') }} — private pulls will
+ fail here. Add the credential below.
+
- Nothing stored. Public images need no credential — this is only for a private registry.
-
+
No credentials yet. Add one below.
Add a credential
-
- Applies to the next pull — nothing needs restarting.
-
- The docker client re-reads config.json on every invocation, so there is no cache
- to bust. Storing one now is enough for a deploy started a second later.
-
-
@@ -215,10 +173,8 @@ async function forget(registry: string): Promise {
-
- Prefer a token scoped to reading packages. Stored on the host as reversible base64, exactly as
- Stored the same way a terminal login stores it — never sent back to this page.
-
+
+
Stored write-only, as reversible base64 — never shown again.
@@ -229,11 +185,6 @@ async function forget(registry: string): Promise {
Storing needs an access token.
-
-
- Or from the host, which writes the same file:
- docker login --config {{ dir || '/control/docker' }} <registry>
-
diff --git a/apps/ui/src/views/RoutingView.vue b/apps/ui/src/views/RoutingView.vue
index e4011b9..8f00d7d 100644
--- a/apps/ui/src/views/RoutingView.vue
+++ b/apps/ui/src/views/RoutingView.vue
@@ -15,7 +15,7 @@
*
* What it cannot break: `control.` and `api.` are docker labels on the pstack
* container, not file config. This page cannot lock you out of the page you would use to undo a bad
- * save. That is stated in the UI, because an operator hesitating over a save deserves to know it.
+ * save. That reassurance lives in docs/control-plane.md, not in this view.
*
* `previous` from a save is an in-session undo. There is deliberately no history on disk: the obvious
* place to keep it is the one directory that must contain nothing but dynamic config.
@@ -35,9 +35,7 @@ import { stamp } from '../composables/useFormat';
import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import InfoHint from '../components/InfoHint.vue';
import SkeletonList from '../components/SkeletonList.vue';
-import HelpModal from '../components/HelpModal.vue';
import RefreshButton from '../components/RefreshButton.vue';
import RouteTarget from '../components/RouteTarget.vue';
@@ -181,7 +179,7 @@ async function save(): Promise {
if (r.body.previous) undo.value = { name, content: r.body.previous };
original.value = draft.value;
openName.value = name;
- toast('ok', `Saved ${name}. Traefik reloads within a second or two.`);
+ toast('ok', `Saved ${name}.`);
void loadList();
}
@@ -213,39 +211,8 @@ async function remove(): Promise {
-
- Routing
-
-
- Traefik reads this directory as a whole. One file it cannot parse and it reports a
- problem for the directory — so the symptom is other, unrelated routes
- disappearing rather than an error about the file you just saved.
-
-
- Saves here are checked before they are written and replace the old file in one step, so a
- file that would break the directory never reaches Traefik in the first place.
-
-
- This page cannot lock you out. It and the API are routed by their containers'
- own settings, not by these files — no save here can take away the page you would use to
- undo it.
-
-
- What belongs here: shared middleware (sign-in prompts, rate limits, address
- allow-lists), TLS options, the catch-all fallback, and routes to things running outside
- this host's previews. A single preview's own routes are not here — those travel with the
- deployment.
-
-
-
-
- Every route on this host, and the config files behind them
-
- Middleware (basic auth, rate limits, IP allow-lists), TLS options, the catch-all fallback
- router, and routes to anything running outside compose. Per-PR routers are not here —
- those are labels in each deployment's own compose file.
-
-
+
Routing
+
Every route on this host, and the files behind them
@@ -258,7 +225,7 @@ async function remove(): Promise {
Replaced {{ undo.name }}.
- The previous contents are still here for this session.
+ Previous contents kept for this session.
@@ -268,19 +235,12 @@ async function remove(): Promise {
Live routes
-
- from container labels
-
- Traefik reads two providers. Per-PR routers are labels on containers, written by each
- deployment's own compose file — so deploying a stack changes this list and never the files.
- The files are the other provider: middleware, TLS options, the fallback router.
-
-
+ from container labels
Docker did not answer.
-
Routes could not be listed — which is not the same as there being none.
+
Routes are unknown, not none.
@@ -337,11 +297,7 @@ async function remove(): Promise {
-
- No container on this host declares a Traefik router. A deployment's routes come from labels in
- its own compose file — open a deployment's Containers & routes tab to see what is
- missing.
-
+
No routes yet — deploy a stack.
@@ -355,24 +311,20 @@ async function remove(): Promise {
- Read-only: the API cannot write to this directory.
-
- The control stack mounts it into the API from 0.4.0 onward. Re-run
- setup again on the host to pick up the mount — it is safe to repeat, and it recreates
- the control containers.
-
+ Read-only: the API cannot write here.
+
Re-run setup on the host to add the mount.
- Viewing needs a token too.
+ Viewing needs a token.
- These files hold basic-auth hashes and forward-auth URLs, so their contents are not served
- unauthenticated. Add your token.
+ These files hold basic-auth hashes.
+ Add your token.
@@ -387,9 +339,7 @@ async function remove(): Promise {
-
- No files yet. Everything is routed by container labels, which is a perfectly good place to be.
-
+
No files yet — everything is routed by labels.
@@ -416,10 +366,8 @@ async function remove(): Promise {
- Hidden without an access token.
-
- Add your token to read and edit this file.
-
+ Hidden without a token.
+
Add your token to edit this file.
@@ -440,8 +388,7 @@ async function remove(): Promise {
Delete this file
- Whatever it configures stops applying as soon as Traefik reloads. If it holds a router,
- those hostnames stop resolving to anything.
+ Its routes and middleware stop applying on the next Traefik reload.
@@ -321,8 +295,7 @@ const roleOptions = ROLES.map((r) => ({ value: r, label: sentence(r), hint: ROLE
Connected to pstack {{ state.health.version }}
- Deployment records and stored specs live in
- {{ state.health.dataDir }} on the host.
+ Data directory: {{ state.health.dataDir }}{{ state.healthError }}
@@ -332,16 +305,7 @@ const roleOptions = ROLES.map((r) => ({ value: r, label: sentence(r), hint: ROLE
This host
-
- Stored on the server, in force for everybody, and saved on purpose — a box that wrote
- through on every keystroke would set the limit to 1 on the way to typing 12.
-
- Database, then environment, then the value the binary ships with. Something saved here
- outranks PSTACK_MAX_JOBS and is not undone by the next restart — and a host
- where nobody ever saves anything keeps behaving exactly as it did before this panel
- existed.
-
-
+
Stored on the server, in force for everybody.
@@ -377,22 +341,13 @@ const roleOptions = ROLES.map((r) => ({ value: r, label: sentence(r), hint: ROLE
{{ running }} running · {{ waiting }} waiting
-
- A waiting job is not necessarily waiting for a slot: a stack runs one job at a time,
- so a job can be queued behind another on its own stack while the host is nowhere near
- this limit. The Jobs page says which, per job.
-
- The most jobs this host runs at once, across every stack. It applies from the moment you
- save it — no restart. Lowering it cancels nothing: jobs already running run to
- completion, and the new limit applies to the next job that starts. Raising it starts
- whatever has been waiting for a slot.
+ Most jobs at once, across every stack. Lowering it cancels nothing.
@@ -416,24 +371,18 @@ const roleOptions = ROLES.map((r) => ({ value: r, label: sentence(r), hint: ROLE
Save
-
- What an account gets when it is created without a role being named — the API's
- POST /api/users, and any sign-on provider left to inherit the host default.
- It never widens what anyone may do: creating accounts is admin's either way. Accounts
- that already exist keep the role they have.
-
- “Follow the system” uses your device's reduce-motion setting. The other two override it,
- in either direction.
-
diff --git a/apps/ui/src/views/SpecDetailView.vue b/apps/ui/src/views/SpecDetailView.vue
index 7311acc..02e57bd 100644
--- a/apps/ui/src/views/SpecDetailView.vue
+++ b/apps/ui/src/views/SpecDetailView.vue
@@ -5,7 +5,7 @@
* THE SOURCE IS A RESTRICTED READ. Everything else on this API can be read without a token, but a
* spec's hook bodies are shell strings that routinely carry a credential inline, so the server sends
* `source` only to an authenticated request and sets `sourceWithheld` otherwise. That is why this
- * one view uses `getAuthed`, and why "no token" renders an explanation rather than an empty editor —
+ * one view uses `getAuthed`, and why "no token" renders a named banner rather than an empty editor —
* a blank box would read as an empty spec, which is a different and much more alarming fact.
*/
import { computed, ref, watch } from 'vue';
@@ -76,7 +76,6 @@ const users = computed(() => state.deployments.filter((d) => d.specName === prop
No spec named “{{ name }}”.
-
It may have been deleted, or this is an older link.
@@ -88,14 +87,12 @@ const users = computed(() => state.deployments.filter((d) => d.specName === prop
Needs
- Variables the spec uses but does not set. Every deployment referencing it supplies
- these, which is how one spec serves many stacks — and a missing one is refused by
- name rather than filled in blank.
+ Variables the spec uses but does not set.
{{ spec.requiredVars.join(', ') }}
- nothing — every value is fixed by the spec
+ nothing
@@ -109,13 +106,8 @@ const users = computed(() => state.deployments.filter((d) => d.specName === prop
style="margin-right: 10px"
>{{ d.id }}
-
- Deleting a spec while a deployment still references it is refused: that deployment
- could no longer be resolved, and one that cannot be resolved can never be torn
- down.
-
- no deployments reference it
+ none
- A spec's hooks are shell commands, and they often carry a password or an API token
- inline — so the server does not send the file to an unauthenticated reader.
- Add your token to see it.
-
+
Add your token to see it.
{{ spec.source }}
-
The server returned no source for this spec.
+
+
No source.
diff --git a/apps/ui/src/views/SpecsView.vue b/apps/ui/src/views/SpecsView.vue
index 8fd91f8..488f5d0 100644
--- a/apps/ui/src/views/SpecsView.vue
+++ b/apps/ui/src/views/SpecsView.vue
@@ -8,7 +8,7 @@
* torn down — so knowing the count before you try is the difference between a plan and a 409.
*
* A server built before named specs answers 404 here. That is a capability difference, not an
- * error, so it gets its own explanation instead of a red banner.
+ * error, so it gets its own plain banner instead of a red one.
*/
import { computed, ref } from 'vue';
import { sentence } from '../composables/useFormat';
@@ -75,13 +75,7 @@ const shown = computed(() => {
Specs
-
- Stored once, used by any number of deployments
-
- Without this, every deployment carries its own copy — 50 open pull requests meant 50
- identical files, and fixing a teardown step meant re-submitting it 50 times.
-
-
- It is an older build of pstack — every deployment carries its own copy of its spec, which
- still works. Upgrade the host to store specs and reference them by name.
-
needs
- Variables the spec uses but does not set. Every deployment referencing it must
- supply these, which is how one spec serves many stacks.
+ Variables the spec uses but does not set.
- A deployment can carry its own spec inline — see
- Submit — and that is all you need for a one-off.
- Storing one here pays off when several deployments share it.
-
-
This page cannot store one yet — the command below does.
+ No specs yet — store one below. {
return;
}
formError.value = '';
- toast('ok', e.form.mode === 'oidc' ? 'Saved — the issuer answered, so the settings are known good.' : 'Saved.');
+ toast('ok', 'Saved.');
editing.value = null;
void load();
}
@@ -329,7 +327,7 @@ async function remove(entry: SsoProviderEntry): Promise {
listError.value = problem(r, 'remove the provider');
return;
}
- toast('ok', `Removed ${entry.key}. Existing accounts keep working.`);
+ toast('ok', `Removed ${entry.key}. Its accounts keep working.`);
void load();
}
@@ -338,7 +336,7 @@ async function copyCallback(): Promise {
await navigator.clipboard.writeText(callbackUrl.value);
toast('ok', 'Copied.');
} catch {
- toast('error', 'Could not reach the clipboard — select the text and copy it.');
+ toast('error', 'Copy failed.');
}
}
@@ -378,42 +376,8 @@ function rulesLine(c: SsoConfig): string {
-
- Single sign-on
-
-
- This service is the relying party and nothing more. You register an OAuth or
- OIDC application in your own organisation and paste its client id and secret here. Your
- directory stays yours — no accounts are copied, and nothing is synchronised.
-
-
- Anyone who authenticates gets an account, created on their first sign-in. That is
- deliberate: your provider already decides who may authenticate (Workspace restricts to
- internal users, a GitHub app can be org-approved), and duplicating that policy here
- would just be a second list to keep in step. Narrow it with the sign-in rules on each
- provider if you need to.
-
-
- Identity is the provider's subject, not the email. Someone changing their
- address keeps their account, their history and their tokens. An address is only used to
- adopt an account that already exists here, and only when the provider says it is
- verified.
-
-
- Several providers share the accounts. Each shows up as its own button on the
- login page, and the same person arriving from the same directory is the same account —
- nothing about local accounts or personal tokens changes either way.
-
-
-
-
- Let people sign in with the identity providers you already run
-
- One row per provider: the endpoints, the client id, and the client secret. The secret has
- no read path — this page only learns whether one is stored, and leaving the field empty
- keeps it.
-
-
+
Single sign-on
+
Sign in with the identity providers you already run
@@ -439,10 +403,8 @@ function rulesLine(c: SsoConfig): string {
Enabled
-
+
- Register the app with {{ editing.preset.label }}
-
- Paste this into the provider's application as the redirect / callback URL. It has to
- match byte for byte, and it is the same for every provider on this host — a mismatch is
- the single most common reason the exchange fails, and the provider's error rarely says
- so.
-
+
Must match the provider's registered callback exactly.
Key
-
- This host's name for the provider — it appears in the sign-in URL and in exports, never
- to the person signing in. Lowercase letters, digits and dashes.
-
+
Lowercase letters, digits, dashes.
Button text
-
The login page draws it as “Continue with {{ editing.form.label || editing.preset?.label || '…' }}”.
+
“Continue with {{ editing.form.label || editing.preset?.label || '…' }}”
@@ -490,22 +444,13 @@ function rulesLine(c: SsoConfig): string {
Issuer{{ assembledDiscovery }}
-
- Assembled from the value{{ placeholderNames.length > 1 ? 's' : '' }} above —
- {{ editing.preset?.label }} gives every account its own issuer, so there is no single
- URL a preset could carry. It is fetched when you save, so a typo is refused here
- rather than at someone's first login.
-
+
Fetched when you save.
Issuer
-
- Or the full …/.well-known/openid-configuration URL. Everything but the
- client id and secret is discovered from it, and it is fetched when you save — so a typo
- is refused here rather than at someone's first login.
-
+
Fetched when you save.
@@ -518,7 +463,7 @@ function rulesLine(c: SsoConfig): string {
Write-only.
- {{ editing.secretSet ? 'A secret is stored — leave this empty to keep it.' : 'Stored on save and never returned by any route.' }}
+ {{ editing.secretSet ? 'Leave empty to keep the stored secret.' : 'Never returned once saved.' }}
@@ -544,36 +489,18 @@ function rulesLine(c: SsoConfig): string {
User info URL
-
Leave empty to take the identity from the token response alone — a subject and nothing else.
Emails URL (optional)
-
- Only consulted when the profile carries no address — which is GitHub's default. Filled
- in automatically for a preset, and needed only if you pointed the user info URL at your
- own host.
-
Groups URL (optional)
-
- Where this host asks which groups someone is in. Only consulted when there is a group
- rule below. Filled in automatically for a preset — but only while the user info URL is
- still the preset's, because a self-hosted host's token must not be sent to the public
- one, so a self-hosted provider needs this typed.
-
-
- Claim mapping
-
- Which key of the provider's user response holds each field. Flat lookups, not
- expressions. A preset fills these in; you only touch them for a provider nobody has
- written a preset for.
-
-
+
+
Claim mapping
{{ k }}
@@ -585,23 +512,15 @@ function rulesLine(c: SsoConfig): string {
-
Who gets an account, and as what
+
Who gets in
- By default, anyone this provider lets authenticate. It already owns that decision; this is
- only here for the case where the application you registered is broader than the people who
- should reach this control plane. Any one entry in a list is enough to satisfy that
- list, and every list you fill in has to be satisfied — fill all three in and a login
- needs a listed domain, a matching username, and one of the groups. A list left empty is not
- a rule at all: it lets everyone past that check, it does not lock everyone out.
+ Every filled list must match; empty lists restrict nobody.
Allowed email domains
- Comma-separated; subdomains count. Empty means no restriction. Non-empty means a login
- with no email address at all is refused too — a provider that hides the address (a
- private GitHub profile with no user:email scope) cannot be checked, so it is
- not waved through.
+ Comma-separated; subdomains count. When set, a login with no address is refused.
- Comma-separated patterns, case-insensitive: *, ? and
- [0-9] classes. Empty means no restriction. Non-empty means a login whose
- provider sends no username is refused too — so this is a rule for a provider that
- actually has usernames (GitHub's login, GitLab's username). On
- an OpenID Connect issuer that sends no preferred_username it refuses
- everyone rather than nobody, so set it only where you know the provider sends one.
+ Comma-separated patterns: *, ?, [0-9].
+ When set, a login with no username is refused.
@@ -622,26 +537,16 @@ function rulesLine(c: SsoConfig): string {
Required groups
- Comma-separated, and exact names, not patterns — an organisation login, or a group
- path like acme/backend. Case-insensitive. Empty means no restriction.
- Non-empty makes every sign-in ask the provider for that person's groups, so it needs the
- OAuth scope that endpoint requires: add it to Scopes above, or the save is refused
- — and the refusal names the scope. It also needs a provider whose group list this host
- knows how to read, which the save checks by name. If that call does not answer, the
- sign-in is refused rather than allowed.
+ Exact names, not patterns, comma-separated. Needs the matching scope above, or
+ the save is refused.
-
- A group rule needs a provider whose groups endpoint this host knows how to read — an OAuth
- 2.0 preset (GitHub, GitLab), not a discovered OpenID Connect issuer — so there is none
- here.
-
- Role for the accounts it creates
+ New account role
- Applied when an account is created here — on someone's first sign-in — and never after:
- promoting or demoting a person later is done in Accounts, and this does not reach back
- and change them.
- The host default follows Settings rather than being copied in now, so raising or
- lowering it there moves this provider with it.
- {{ hostDefaultRole ? `It is ${hostDefaultRole} today.` : '' }}
- Picking a role here pins this provider to it instead.
+ Set at first sign-in. Host default follows
+ Settings.
@@ -678,11 +578,7 @@ function rulesLine(c: SsoConfig): string {
-
- Pick where your team already signs in. A preset knows the provider's endpoints and its
- quirks — the only things it cannot know are the client id and secret you get when you
- register the app, and the form that opens says exactly where to do that.
-
+
Pick where your team already signs in.
-
- Removing a provider does not delete anyone. Those accounts keep their personal tokens, and
- pointing the same provider back at this host re-links them by subject.
-
Callback URL
-
- The redirect URI to register with every provider — one URL for all of them, built by the
- server so it cannot drift from the one the sign-in flow actually sends.
-
{{
replacing
- ? 'Loaded from what is stored; saving replaces the record'
+ ? 'Saving replaces the record'
: copyFrom
- ? `Copied from ${copyFrom} — give it a new id, then edit`
- : 'Stores a spec so it can be deployed'
+ ? `Copied from ${copyFrom} — give it a new id`
+ : 'Store a spec, then deploy it'
}}
@@ -328,12 +328,6 @@ function loadExample(): void {
moment of commitment is where a guarantee about committing belongs, not floating above
an unrelated id field. -->
-
Replacing {{ id }}.
-
Loading what is stored…
+
Loading…
- Loaded from the stored record — edit it and save. Saving replaces the record outright, so
- anything you delete here stops being tracked while whatever it created keeps running.
+ Saving replaces the record outright — anything you delete stops being tracked but keeps
+ running.
- The stored spec was not loaded: reading it needs an access token, because hooks routinely
- carry a credential inline. Add your token to edit
- in place — otherwise paste the complete spec, since anything you leave out stops
- being tracked.
+ Stored spec withheld — add your token, or paste the
+ complete spec. Anything you leave out stops being tracked but keeps running.
- The stored spec could not be loaded, so this form is empty. Paste the complete spec
- you want stored — anything you leave out stops being tracked while whatever it created keeps
- running.
+ Stored spec unavailable. Paste the complete spec — anything you leave out stops being
+ tracked but keeps running.
@@ -378,11 +370,11 @@ function loadExample(): void {
following it. Silent forks are the kind of thing discovered months later.
-->
- This deployment tracks the stored spec “{{ forkWarning }}”.
+ Tracks the stored spec “{{ forkWarning }}”.
- Saving here writes a private copy and stops it following that spec. To change every
- deployment using it, edit
- the spec instead.
+ Saving forks it —
+ edit the spec
+ to change every deployment using it.
@@ -401,8 +393,7 @@ function loadExample(): void {
Lower case
- Starts with a letter or digit, then letters, digits, dot, dash or underscore. Up to 64
- characters — [a-z0-9][a-z0-9._-]{0,63}.
+ [a-z0-9][a-z0-9._-]{0,63}
@@ -427,10 +418,6 @@ function loadExample(): void {
needs
{{ chosenSpec.requiredVars.join(', ') }}no variables.
-
- A missing variable is refused by name. It is never filled in with a blank, which would
- silently give every deployment the same stack name.
-
@@ -453,28 +440,15 @@ function loadExample(): void {
v-model="form.compose"
rows="6"
spellcheck="false"
- placeholder="Written next to spec.yml, so a compose file named compose.yml resolves"
+ placeholder="Written next to spec.yml"
/>
-
- Hooks must be inline shell or an absolute path.
-
- A deployment directory only ever holds spec.yml and
- compose.yml, and hooks run from there — so
- up: ./hooks/db.sh has nothing to find.
-
-
+
Hooks must be inline shell or an absolute path.
Variables
-
- Saved with the deployment, so tearing it down later targets the same stack.
-
- They are stored on the server where it supports that, and in this browser either way — so
- the actions on the deployment page send exactly these values.
-
-
+
Saved with the deployment, so teardown targets the same stack.
@@ -482,11 +456,6 @@ function loadExample(): void {
Nothing is saved until the spec is valid
-
- The spec is checked before anything is written, so a rejected submission leaves nothing
- behind — and a replace never destroys a good record over a typo. Whether the deployment
- is shared or isolated comes from the spec, not from this form.
-
{{ result.created ? 'Created' : 'Replaced' }} — stack
@@ -508,14 +477,13 @@ function loadExample(): void {
it can be deliberate, and refusing over a guess would be worse than saying so.
-->
- Another deployment already resolves to stack “{{ result.stack }}”.
+ Stack “{{ result.stack }}” is already in use.
{{ o }}, — both records now drive the same compose project, so down on either stops the
- other's containers and verify on either reports the other's leaks. If that was not
- intended, change this one's stack and save again.
+ >— down on either stops both. Change this one's stack if that was
+ not intended.
@@ -525,11 +493,7 @@ function loadExample(): void {
point is to reach the author while they are still looking at the file they just wrote.
-->
- Swarm will not run this compose file as written.
-
- Some keys are dropped and others are rewritten — docker stack deploy reports
- that once, to a terminal nobody is watching. Every change it makes to what you submitted:
-
+ Swarm will change this compose file.
{{ n }}
diff --git a/apps/ui/src/views/SwarmView.vue b/apps/ui/src/views/SwarmView.vue
index c131b44..5559423 100644
--- a/apps/ui/src/views/SwarmView.vue
+++ b/apps/ui/src/views/SwarmView.vue
@@ -19,8 +19,6 @@ import { sentence } from '../composables/useFormat';
import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import HelpModal from '../components/HelpModal.vue';
-import InfoHint from '../components/InfoHint.vue';
import RefreshButton from '../components/RefreshButton.vue';
import SelectMenu from '../components/SelectMenu.vue';
import SkeletonList from '../components/SkeletonList.vue';
@@ -54,10 +52,10 @@ const revealing = ref(false);
const joinError = ref('');
const FORMATS: Array<{ value: Format; label: string; blurb: string }> = [
- { value: 'command', label: 'docker swarm join …', blurb: 'One line, for a machine that already runs Docker.' },
- { value: 'script', label: 'Shell script', blurb: 'Installs Docker if missing (get.docker.com), then joins.' },
- { value: 'cloud-config', label: 'cloud-config', blurb: 'User-data for a fresh cloud machine: Docker from the distro, then join.' },
- { value: 'token', label: 'Token only', blurb: 'The bare join token, for your own tooling.' },
+ { value: 'command', label: 'docker swarm join …', blurb: 'For a machine already running Docker.' },
+ { value: 'script', label: 'Shell script', blurb: 'Installs Docker, then joins.' },
+ { value: 'cloud-config', label: 'cloud-config', blurb: 'User-data for a fresh machine.' },
+ { value: 'token', label: 'Token only', blurb: 'For your own tooling.' },
];
async function reveal(): Promise {
@@ -68,11 +66,11 @@ async function reveal(): Promise {
const r = await api.getText(`/api/swarm/join?${q}`);
revealing.value = false;
if (r.status === 403) {
- joinError.value = 'The join token is admin-only. Sign in as an admin, or use the machine token.';
+ joinError.value = 'The join token is admin-only.';
return;
}
if (r.status === 409) {
- joinError.value = 'This host is not a swarm manager, so there is nothing to join. Nothing was revealed.';
+ joinError.value = 'This host is not a swarm manager.';
return;
}
if (!r.ok) {
@@ -87,7 +85,7 @@ async function copy(): Promise {
await navigator.clipboard.writeText(revealed.value);
toast('ok', 'Copied.');
} catch {
- toast('error', 'Could not reach the clipboard — select the text and copy it.');
+ toast('error', 'Could not copy.');
}
}
@@ -101,36 +99,10 @@ onBeforeUnmount(() => {
-
- Swarm
-
-
- This host is the manager. Previews deploy as swarm stacks — the compose file you
- submit is converted on every deploy (profiles resolved, restart and limits
- moved under deploy, Traefik labels onto the service) — and their tasks run
- on whichever node has room.
-
-
- A worker is one command away. Open the ports below between the machines, run the
- join material on the new one, and it appears in this table. Nothing else on this host
- changes: the control plane stays here, the axes and the leak gate never cared which node
- a container landed on.
-
-
- What a worker cannot do. A shell and stop/start reach only tasks on this node
- (docker's verbs are node-local); logs reach every node through the manager. A volume a
- task creates on a worker stays there when the stack is torn down — use named volumes
- only for data you can lose, and an axis for data you cannot.
-
-
-
-
- The nodes previews run on, and how to add one
-
- docker node ls on this host, every ten seconds. Nothing is stored — a node that
- leaves disappears from here the moment docker stops listing it.
-
-
+
+
Swarm
+
The nodes previews run on
@@ -152,8 +124,7 @@ onBeforeUnmount(() => {
- Docker did not answer.
-
Nothing about the swarm is known right now — which is not the same as there being no swarm.
+ Docker did not answer — this is not the same as no swarm.
Not a swarm manager.
@@ -194,7 +165,7 @@ onBeforeUnmount(() => {
-
Docker lists no nodes — on a manager that should at least be this one.
+
Docker lists no nodes.
@@ -204,10 +175,7 @@ onBeforeUnmount(() => {
Add a worker
-
- Open these between the new machine and every other node first — a worker that cannot reach
- them joins and then never receives a task.
-
+
Open these between the new machine and every other node.
- Give me
+ Format (format = v as Format)"
/>
@@ -231,7 +199,7 @@ onBeforeUnmount(() => {
@@ -244,10 +212,10 @@ onBeforeUnmount(() => {
- This is a secret — whoever has it can add a node that runs any task here.
+ Secret, shown once — whoever has it can add a node that runs any task here.
- Shown once; it is not stored in this page and is forgotten when you leave. Rotate it on the
- manager with docker swarm join-token --rotate worker if it leaks.
+ If it leaks, rotate it on the manager:
+ docker swarm join-token --rotate worker
{{ revealed }}
diff --git a/apps/ui/src/views/UsersView.vue b/apps/ui/src/views/UsersView.vue
index da117e2..275e2b9 100644
--- a/apps/ui/src/views/UsersView.vue
+++ b/apps/ui/src/views/UsersView.vue
@@ -40,7 +40,6 @@ import { toast } from '../composables/useToasts';
import ActionButton from '../components/ActionButton.vue';
import EquivalentCommand from '../components/EquivalentCommand.vue';
import ErrorNote from '../components/ErrorNote.vue';
-import InfoHint from '../components/InfoHint.vue';
import RelativeTime from '../components/RelativeTime.vue';
import SkeletonList from '../components/SkeletonList.vue';
import RefreshButton from '../components/RefreshButton.vue';
@@ -119,7 +118,7 @@ async function createUser(): Promise {
listError.value = problem(r, 'create this account');
return;
}
- toast('ok', `Created ${r.body.user.username} as a ${r.body.user.role}.`);
+ toast('ok', `Created ${r.body.user.username}.`);
newUser.value = { username: '', password: '', role: 'viewer' };
void load();
}
@@ -150,7 +149,7 @@ async function changeRole(u: User, role: string): Promise {
// or the UI keeps offering what the server will now refuse. (The server needs no prompting:
// every request looks the role up fresh.)
await checkAuth();
- toast('ok', `You are now a ${role}. What this app offers you changed with it.`);
+ toast('ok', `You are now a ${role}.`);
} else {
toast('ok', `${u.username} is now a ${role}.`);
}
@@ -167,13 +166,11 @@ async function changePassword(): Promise {
}
pwFor.value = null;
pwValue.value = '';
- // Not a footnote: the change signs that user out everywhere, and if it was YOU, the next request
- // is a 401. Saying so here is the difference between an expected step and an apparent bug.
+ // If it was YOU, the next request is a 401 — the toast says to sign in again so that reads as an
+ // expected step rather than a bug. The dialog carried the full warning before the change.
toast(
'ok',
- u.id === authState.user?.id
- ? `Password changed. You have been signed out everywhere — sign in again.`
- : `Password changed. ${u.username} has been signed out everywhere.`,
+ u.id === authState.user?.id ? `Changed — sign in again.` : `Password changed.`,
);
void load();
}
@@ -189,7 +186,7 @@ async function createToken(): Promise {
revealed.value = {
what: `Personal token “${newToken.value.trim()}”`,
value: r.body.token,
- note: 'Send it as a bearer token. The server stores only a hash, so this is the only time it is shown.',
+ note: 'Shown once — the server keeps only a hash. It can do anything your account can.',
};
newToken.value = '';
void load();
@@ -211,14 +208,7 @@ async function removeToken(t: Token): Promise {
Users & access
-
- Who can sign in, and the tokens they use from scripts
-
- Three kinds of caller. The host token from the environment is the root credential CI
- holds. An account signs in with a password and gets a browser session. A personal token
- belongs to one account and is what a script uses — it can do anything that account can.
-
-
+
Accounts and personal tokens
@@ -228,6 +218,8 @@ async function removeToken(t: Token): Promise {
{{ revealed.what }} — copy it now.
+
{{ revealed.note }}
{{ revealed.value }}
@@ -329,7 +321,7 @@ async function removeToken(t: Token): Promise {
@update:model-value="(v) => (newUser.role = v as Role)"
/>
-
At least 8 characters. A new account starts as a viewer unless you say otherwise.
- Only an admin can add an account or change a role. Ask one of the admins above.
-
+
+
Only an admin can add accounts or change roles.
Your API tokens
-
- A script or a CI job signing in as you. It carries whatever you can do, so give each one
- a name you will recognise later and revoke the ones you stop using.
-
- {{ authState.root ? 'The host token is not an account, so it has no personal tokens.' : 'No tokens yet.' }}
+ {{ authState.root ? 'The host token has no personal tokens.' : 'No tokens yet — name one below.' }}