461 Commits

Author SHA1 Message Date
librelad
601ea03b1a Backup step: drop the timestamp list, and make both answers cards
The pre-password "Taken" list goes. It could only ever be a column of
timestamps, and the card directly above it already said how many snapshots
there were and how recent the newest was — so it answered a question that had
just been answered. The real choice now lives on Contents, where snapshots have
names. The times came out of the scan and verify payloads with it; carried but
unread is debt.

The read result is a card too, matching the folder's. They were a card and a
sentence sitting one above the other, looking like two different kinds of
thing. Shortened to the host and what it holds — "Change-Me · settings + 2
apps". No "continue to see what will happen": Next is right there and has just
become available, which says it better.

And a real bug, caught by asserting Next's state after a genuine read rather
than a simulated one: readBackup CLEARS the password field the moment it hands
the value to the host, so a gate that re-checked the source fields reported a
missing password about a repository it had already opened. Next stayed disabled
for good after a successful read. Both the button and validateStep now treat an
open backup as settling the question — they have to agree, because
enabled-but-refused is worse than either alone.

The test that caught it could not run at first: BACKUP was declared after the
block using it, so the whole eval died in the temporal dead zone and reported
as "the browser failed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:14:17 +01:00
librelad
6018250526 Choose which snapshot to restore, per app and for the settings
A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.

Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.

The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:

  - restoreInspect returns every snapshot per app and for the settings, not
    just the newest.
  - restoreFirstRunBulk reads an optional RESTORE_SNAPSHOT_CHOICE map instead
    of hardcoding "latest". An associative array rather than an argument,
    because the CLI wrapper pads argv to nine slots and a per-app map cannot
    survive it; the map reaches the host as base64 JSON, validated at the route
    against restic short ids and app names since both hit a command line.
  - backupRestoreSystemConfig takes a snapshot AND a host.

That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.

Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.

Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.

lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:44:21 +01:00
librelad
94683db240 Backup step: snapshot times, a disabled Next, and readable errors
Three things.

The snapshot times now sit under the count. They are the only thing about a
snapshot legible without the key — the filename is an opaque hash and
everything describing what is inside is in the encrypted object — so they
answer "is this the backup I think it is, and did it run when I expect", which
is the question someone actually has before typing a password into it.

Next is disabled until the backup has been opened, rather than accepting the
click and then arguing. validateStep still refuses, for anyone who arrives
another way, but the disabled state says "something above me is unfinished"
before the click instead of after. It re-enables on a successful read and goes
back to disabled the moment the path, type or password changes, since that read
is then about a different repository.

Error text was rgb(220,53,69) on a 10%-opacity danger background — a mid red on
a dark blue panel, legible in theory and squinted at in practice. Lighter text,
a firmer border, more line-height. The test asserts perceived brightness rather
than an exact colour, so a theme change cannot quietly undo it.

One real bug on the way: _adoptSingleResult rebuilt the record by hand, naming
four fields, so `times` was dropped and the list came out empty even though the
data was right there. It passes the whole record through now — a field lost
that way is invisible until something downstream needs it.

Not done, and worth stating plainly: these times are not selectable. Picking
one would be picking a hash — a restic snapshot is ONE app's data or the
settings tree, not a whole machine, and which is which cannot be known until
the repository is open. Choosing a point in time to restore from is a real
thing to want and belongs on Contents, after unlocking, where the snapshots
have names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:43:02 +01:00
librelad
f736ec6501 Drop the section headings — the step name is already at the top
Every step's name is in the progress bar. Repeating it as a heading directly
beneath was the same word twice with nothing between them: "Step 4 of 8 —
Storage" followed immediately by "STORAGE". Eight headings across both
branches, all gone.

Three of them carried a tooltip worth keeping — what a storage location is,
that a backup password cannot be recovered, what a .lpapp is. Those moved to a
tooltip beside the step name in the progress bar, which is where the heading
actually is now. Steps that never had an explanation show no marker.

Also removed the Backup step's "Where is it? Backups live in a repository…"
paragraph. The step is called Backup, the first field asks where it lives, and
the repository-not-a-file point is what the step tooltip is for.

lp-restore-wizard-test asserted the old heading matched the step name; it now
asserts there are no headings left anywhere and that the progress bar carries
both the name and the explanation.

Both browser tests reported "wizard handle missing" when the wizard simply was
not on screen — it only renders while setup is incomplete, and a stray
.setup_complete from an earlier run hides it. They now say which of the two it
is, since the first sends you looking for a JS error that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:25:14 +01:00
librelad
aff9b398da Backup step: Read sits beside the password, like Check beside the folder
"Read this backup" was a button floating below the form, which reads as a step
of its own rather than as the action for the field above it. It is now inline
in the password row, styled exactly like the folder's Check — each field
carries its own action on the right.

Shortened to "Read" so it fits the row without resizing it mid-request, and the
status line moved inside that field for the same reason: it is an answer about
the password, not about the step.

The gate message names the button it is pointing at, so renaming one meant
renaming the other. That is the kind of drift nothing catches — the message is
only reachable by trying to advance without reading first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:08:47 +01:00
librelad
f77915de3b Backup step: gate Next instead of instructing
"Repository password" is restic's word for it, not the user's. It is the
Backup Password.

And the card no longer ends with "Enter its password below to see what is
inside." That line told someone what a locked door is for while they stood in
front of it, and kept telling them after they had entered one. The step
enforces it instead: a restore cannot advance without a password AND a
successful read, because the next step renders what is IN the backup and that
means it has to have been opened. The message appears when the user tries to
move on, rather than in advance and forever.

Editing the path, the type or the password after a read now invalidates it.
Without that the wizard would carry the previous repository's contents forward
under a new path — a wrong answer that looks entirely plausible, since the
Contents step would render perfectly well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:53:47 +01:00
librelad
853e51baac Show the confirmed backup as a card, not a sentence
Moving the results under the Folder field kept the placement but threw away the
presentation: the card became a line of prose. Path, snapshot count and age
read better as a card, and that is what the found list already used.

One renderer now serves both. The same fact should not look like two different
things depending on whether it came from the scan or from pressing Check — only
whether it is still a choice differs, so a pickable one is a <button> and a
confirmed one is a <div> with a quiet accent edge and no hover. A card that
looks clickable but is not is worse than one that does not.

The geometry moved off the button-only selector for the same reason; the
confirmed card is a div and was collapsing without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:34:55 +01:00
librelad
7fef102369 Put the found backups under the field they fill in
The results were rendered above the whole form, which made them read as a
separate step rather than as an answer about the Folder input. They now sit
under that field, inside its box, alongside the verdict.

What is shown depends on how many were found, because those are different
situations:

  one       it is the answer, not a choice — filled in, with its verdict. No
            card, because the card and the verdict said the same thing twice
  several   listed as buttons, most snapshots first; the field stays empty,
            since this is genuinely ambiguous and guessing would be worse
  none      says so — "we looked and there is nothing here" is information

And a bug: the single result filled in only the PLACEHOLDER, so the field was
still empty and pressing Check replied "give a full path, starting with /"
about the very backup shown directly above it. It fills the value in now, and
shows the verdict straight from the scan rather than making the user press
Check to be told what is already on screen. Never overwrites something already
typed — the user's own answer outranks anything we guessed.

_adoptSingleResult moved into renderFoundBackups: "show what we found, and if
there is exactly one, take it" is one behaviour, and splitting it meant the
test could only reach half of it.

Not done, deliberately: listing individual snapshots before the password. The
count is a directory listing, but each snapshot's identity — host, tags,
contents, when they are from — is in the encrypted object. Unlocked, all that
could be shown is a column of hex IDs and file timestamps, which is not
something anyone can choose between. After unlocking, the Contents step already
lists the settings snapshot and one entry per app, which is how people think
about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:27:16 +01:00
librelad
cf8a4b2c69 Find the backup before asking for its password
The Backup step opened with an empty box and /mnt/usb/libreportal-backups as
the placeholder — a path nobody has, presented as the shape of the answer.
Someone rebuilding a server was being asked to recall from memory the one thing
they came here because they had lost.

Two additions, and the point of both is that neither needs the repository
password. A restic repository keeps one file per snapshot under snapshots/, so
"is there a backup here, and how many" is a directory listing. Nothing is
decrypted — reading what is IN those snapshots is the next step, and that does
need the password.

restore scan looks where a backup actually is: this install's own backups root
(the disk often survives), every location the install already knows about, and
one level under each non-OS mount, a just-plugged-in drive being the other half
of "the system drive died". Bounded to named shapes and maxdepth 1, never a
filesystem walk — a scan nobody waits for is a scan nobody uses. Results are
buttons, most snapshots first, each showing its count and the age of its
newest snapshot; clicking one fills the path in.

restore verify <path> answers the same for a typed path. Its most useful answer
is the near-miss: pointing at the folder that CONTAINS the repositories rather
than at one of them, which it names and offers as a button rather than
explaining the distinction in prose.

A repository is recognised by config plus the snapshots, keys and data
directories together. config alone would match any folder with a file of that
name, and offering a stray directory as someone's backup is worse than finding
nothing.

The placeholder now comes from this machine — the first repository found, or
the install's own backups root — since a placeholder's job is to show the shape
of the answer and only a real one does that. The backups root is in the storage
feed for it.

The found entries are buttons and had to own their geometry: .setup-app-card
carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it
collapsed to one cramped line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:04:15 +01:00
librelad
9bb9ed79a9 Storage defaults: hang them off the mount, not the app-data path
The advanced Storage step offered /mnt/disk/apps/libreportal-system as the
default home for LibrePortal's own tree. A registered location's path is where
APP DATA goes and is usually a subdirectory of the drive, so deriving anything
else from it nests that thing inside the app data — LibrePortal's own files
buried under it, on a path that reads as a mistake because it is one.

Both defaults now come off the location's mount point, which meant adding
"mount" to each entry in the storage feed; only the system block carried one.

  LibrePortal                    /mnt/disk/apps/libreportal-system
                              -> /mnt/disk/libreportal-system
  New apps, unregistered drive   /mnt/disk
                              -> /mnt/disk/libreportal-apps
  New apps, registered location  unchanged — it exists and may hold data, and
                                 proposing a different directory on the same
                                 drive would strand it

Names follow the layout the rest of the product uses (libreportal-system,
libreportal-containers, libreportal-backups) rather than a bare "apps", so a
drive shared with anything else stays legible.

collectStorage() no longer registers the drive picked for LibrePortal. A
storage location is somewhere app data lives; the system tree is not app data
and relocate creates that directory itself as root. Picking a drive there was
producing a location nobody asked for, on a mount chosen for something else.

Also in this change, from the Backup step:

  - The backend-specific fields are boxed under their own heading with a note,
    so choosing SFTP reveals "the SFTP part" rather than three more loose rows.

  - Fields had no vertical spacing. .setup-step gives its DIRECT children a
    16px gap, which is where every other step's fields get theirs; these sit a
    level deeper inside a .setup-section and inherited none of it, so each
    input ran into the next field's label.

  - Two field icons carried U+FE0F. Those codepoints have a text form and the
    selector only requests the emoji one, so they sat on a different baseline
    to the plain emoji beside them — the box measured perfectly centred while
    the glyph did not look it.

  - ?mode=restore&type=sftp makes the restore branch reachable by URL. Getting
    there previously took a click and a change event, so every screenshot and
    test had to drive the page before it could look at it.

Two test bugs fixed while doing it: a duplicate `const visible` in one scope
(a parse error, so the whole eval silently returned nothing), and a stub that
covered the POST but not the poll, leaving a 60s loop running that kept the
page from ever going network-idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:48:03 +01:00
librelad
42afc20ee0 Backup step: the wizard's own field layout, and a name that matches
Two things about the restore source step were wrong.

The progress bar said "Source" while the heading said "Where is your backup?",
which reads as two different steps. Every other step's section title is its step
name — Storage, Backups, Import — so this one is "Backup" in both places, with
the friendly question moved to the hint where the rest of the wizard puts it.
Contents and Rebuild got the same treatment.

And the fields used the Storage step's label-left rows. That layout suits a
column of dropdowns; a form of typed values in the middle of a wizard that
looks nothing like the rest of it just reads as unfinished. They now use the
same shape as Identity: a label with a tooltip, then an icon beside the input.
Every field has both, including the password.

On automating the relocate: it cannot be a WebUI action, and the reason is the
thing the privilege model rests on. Root helpers have their paths baked at
install so the manager cannot redirect a privileged operation by editing
something it owns; relocating re-bakes those paths, so a helper that did it
from a caller-supplied path would hand the manager the whole trust boundary.
Narrowing to "registry targets only" does not help either — the manager can add
to that registry by design. libreportal-relocate says this at the top and is
deliberately outside the manager's sudoers.

What was fixable is the part that actually annoyed — being handed a command
with no idea whether it worked:

  - Copy button, with a fallback that selects the text and names the keys,
    because clipboard access needs a secure context and a LAN install on
    http:// is not one.
  - The pending move persists to localStorage, not session state: it happens in
    a terminal minutes or days later, after the tab is gone.
  - A watcher on every page shows the outstanding command, polls for the move
    landing, and reloads to the homepage — whatever route you were on belonged
    to the old install.

"Landed" means the host REPORTS its system dir as the target, which is why
system_dir is now in storage.json. Deliberately not "the server restarted": an
ordinary container restart is indistinguishable, and announcing a relocation
that never happened is worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:10:27 +01:00
librelad
429ec3fa2d Contents step: show the two snapshot kinds as two things
A repository holds one system=config snapshot and one snapshot per app. They
are separate because they are used separately: the settings tree is small,
changes rarely and is meaningless per-app, while app data is large, changes at
its own rate, and has to be restorable, movable and ageable on its own — which
is what the per-app tag buys.

The step listed "Apps" and "Domains" as peer sections, which hid that entirely.
It read as though a backup held three kinds of thing, and gave no clue that the
domains come OUT of the system snapshot rather than being a third kind.

Now: a Settings section (one snapshot, dated, saying plainly that it carries
every backup repository with its credentials and is restored first because it
is what makes the others reachable) with the domains nested under it and each
one's DNS verdict; then an App data section, one snapshot each, dated and
sized. A repository with app data and no settings snapshot says so — the
consequence, that repositories and logins do not come back, is not something to
find out afterwards.

restoreInspect emits that shape now, assembled with jq against the discover
JSON rather than by hand-rolled string concatenation, and carries each
snapshot's date: a backup's age is what people actually judge it by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:51:24 +01:00
librelad
a361e38562 Wizard: New install or Restore from backup
The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:04:43 +01:00
librelad
5dd763713d Read a backup repository from the WebUI, and stop adoption breaking things
restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.

restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.

A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.

Three things found by using it:

  - locationRemove never worked. It unlinked as the container user, but
    configs/ is manager-owned, so it was always denied — and the result was
    never checked, so isSuccessful printed anyway and a "removed" location came
    back on the next listing. Now runInstallOp, and the directory is checked.

  - webuiSecretSweep had no callers. An abandoned flow left its repository
    password on disk forever. The sweep now runs in /api/setup/secret before
    each write, tied to the one event guaranteed to happen.

  - Adoption took the WebUI down. config-adopt chowned every adopted file to
    manager:manager 0640, and webui_logins is bind-mounted into the container,
    which then could not read its own credentials: exit 137 with no log line.
    It also clamped every parent directory it passed through, closing
    configs/webui and configs/backup to the container user.

    The fix is a principle, not a special case: a restore replaces the CONTENT
    of a config file and nothing else. The live install already knows who may
    read each one. Adoption preserves the destination's ownership and mode,
    defaults closed only for a file that did not exist, and never
    re-permissions a directory it passes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 04:33:07 +01:00
librelad
00b6926605 Storage step: the dropdown picks a drive, paths are their own section
Follow-up to fd0a0fd, which put a "Custom path…" entry in the drive dropdown.
That was the wrong shape: the dropdown answers "which disk", and an entry
meaning "actually, let me type a directory" sitting in the same list makes
typing one look like one of the normal answers. Picking a disk is the whole
question for most people.

The dropdown now offers drives and nothing else. Exact paths are a section
under it, advanced only — the same reason Metrics is advanced-only, since the
directory under a chosen drive is operator detail and the beginner path
deliberately does not get a wall of that. Beginners get the drive's default,
which is what they would have typed anyway.

Each field is prefilled from the selected drive and follows it when the drive
changes, so a path belonging to the old drive is never left behind. The
LibrePortal row appears only for a non-primary drive: relocating it onto the
drive it already sits on is not a move.

storageSystemChoice stays a drive, and the new storageSystemTarget holds the
relocate path. collectStorage() registers what the dropdowns point at, and a
system directory is not an app-data location — registering .../libreportal-system
as one would be wrong. Asserted directly.

The validator skips an untouched default: that is whatever the install already
uses, and second-guessing it would reject a legitimate layout.

lp-storage-custom-test -> lp-storage-step-test, and it no longer waits for a
drive to happen to be unplugged: it injects an unmounted candidate and
re-renders, so the offline assertions run everywhere rather than only on a
machine where ambient state obliges. That injection had its own trap worth
recording — renderStorage() rebuilds the selects, so a reference held across it
points at a detached node and setting .value on it succeeds while changing
nothing. Three assertions passed against a control no longer in the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:56:12 +01:00
librelad
fd0a0fd08c Storage step: say "not connected", and allow custom paths
A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.

Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.

A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.

libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.

Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.

lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:

  - It counted the cards that say "not connected" and asserted over those.
    Turn the feature off and the count is zero, every() over an empty list is
    true, and the block passed having checked nothing. The expectation now
    comes from the feed.

  - Both browser tests exited 0 whenever the page returned nothing. Under sudo,
    where chromium will not start, they reported PASS having asserted nothing.
    They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
    browser is the only thing that can have broken, and that is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:09:25 +01:00
librelad
2d5674108b setup: registering a drive made it disappear from the Storage step
storage.json carries two lists: `candidates`, drives that could be added, and
`locations`, the ones already registered. The wizard read only the first. So a
drive vanished from Storage the moment it was registered — the step fell back to
"Only one drive found, so everything goes here" on a box with three, and because
the two root dropdowns only render when there is more than one option, the
choice they exist to offer disappeared with it.

A registered location is the clearest case of a usable drive there is. Read both
lists, deduplicated by path since one can appear in both while a registration
settles.

The generator's location entries carried no size or free figures either, so
those cards rendered as "free of" with both numbers missing next to a system
disk that had them. They now carry size, free, fstype and used_pct like the
system entry, and the card shows the name the user chose rather than the raw
path.

Found by the flow test: three registered locations, three apps placed across
them, and a Storage step insisting there was one drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:09:43 +01:00
librelad
99e81e9ab8 backup: LibrePortal Connect as a destination type, greyed out until it exists
promise.md names Connect as a paid service for "keeping off-site backups", with
two constraints that are load-bearing rather than marketing: it never sees your
data, and every hosted service has a free equivalent in the open code. Nothing
was implemented — no endpoint, no account, no client support.

The client half turns out to be almost entirely there, because a Connect
destination is not a new kind of thing: it is a restic REST repository whose
password never leaves the machine. So `connect` resolves exactly like `rest` in
resticLocationUri — it IS one. It is a separate TYPE only so the UI can tell it
apart from a REST server someone runs themselves, which are identical on disk.

Availability is data, not code: CFG_BACKUP_CONNECT_ENDPOINT (empty) is reported
through the locations feed as connect:{available,endpoint}, and the wizard
renders from that — the option present but disabled, its panel saying what it
will be and that SFTP and S3 do the same job today. The day the service exists,
setting that one value turns it on with no release. Verified both ways.

The device code is a credential, so it goes through the secret channel as a
reference rather than travelling in the wizard payload, which is base64'd into a
world-readable task file.

Design, and what the service still has to provide, in
docs/roadmap/connect-backup-destination.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:21:37 +01:00
librelad
f227435abf setup: put an icon in the slot that was reserving room for one
The storage and destination cards carry a 20px .setup-storage-spacer so they
line up with the app cards, which have icons — so it held space for a picture
and then showed nothing, leaving a gap that reads as something failing to load.

Fill it: a drive for local, a server for SFTP, a cloud for S3/B2, and two linked
nodes for a peer. Inline SVG rather than assets, stroked in currentColor so they
follow the theme and pick up the accent with the rest of the card on hover.

Also drop the standing note under Destinations ("Everything here is on this
machine…"). Each card already says where it points, and the one that shares a
disk with the app data says so on the card itself; a paragraph that never
changes is furniture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:41:26 +01:00
librelad
d51e014cad setup: the wizard's select popups opened behind its own modal
Reported: the dropdowns in Add destination don't work. They rendered correctly,
reported as enhanced, and did nothing when clicked.

custom-select portals its popup into <body> at z-index 1200, chosen — as
forms.css says in as many words — to clear eo-modal at 1100. The wizard raises
its modal to 10000, because at 1100 a modal opened from inside the wizard
rendered behind the wizard itself. That fix silently broke the other invariant:
the popup then opened behind the dialog that owns it. Raise the popup with it,
scoped to the wizard so nothing else's stacking moves.

The test already asserted the select was enhanced, which was true and useless —
the control was enhanced, it just could not be reached. So it now hit-tests:
open the popup and ask what is actually on top at its own centre, then click an
option and check the value, the button label and the field group all follow.
Verified by removing the rule again: two checks fail.

That is the second time this pair has bitten (the modal itself did the same
thing earlier), so the rule and the reason now sit together in one comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:25:41 +01:00
librelad
00114a6ce2 setup: fix the Backups dialog, and make dialogs testable at all
Reported after looking at the step: the add button unstyled, the dialog missing
the fields a backup location actually has, and its dropdowns not working. Three
real faults, and one reason all three shipped.

  * "+ Add destination" carried class .setup-add-domain, which I invented. The
    real one is .setup-domain-add, so no rule matched and it rendered as a bare
    browser button in the middle of a styled form.
  * The dialog asked for name / type / host / user / path / password. A backup
    location has SSH port and auth method (key or password — key is the default
    and needs nothing typed), S3 access and secret keys, B2 account id and key,
    and a path mode. It now asks for what each backend needs, with the wording
    taken from the location config so the wizard and the Backup page describe
    the same thing the same way.
  * .setup-field styled input[type=text] and [type=email] but not [type=password]
    or [type=number], so a credential field and the SSH port rendered unstyled
    even inside a correct container.

Only the credentials go through the secret channel — SSH password, S3 secret
key, B2 account key. The rest is ordinary configuration and travels as itself.

The reason all three shipped is that I checked the step by querying the DOM and
never looked at it. Structural checks cannot see an unstyled control, and a
dialog is behind a click so a screenshot cannot reach it either. So:

  lp-shot --eval <route> <js>   run JS in the page and print the result
  LP_SHOT_EVAL=<js>             run JS before a capture — open a dialog, then shoot

and scripts/dev/lp-backup-dialog-test drives the whole thing in a real browser:
opens it, swaps every backend and asserts only that backend's fields show,
toggles SSH auth and asserts the password field follows, submits, and asserts
the credential is not left in the DOM.

Its styling check needed two attempts, which is the point of mutation-testing
it: "is the background transparent" passes for an unstyled button, because a
native button is grey rather than transparent. It now compares the control
against a bare <button> in the same parent, so "no rule matched" is what fails.
Verified: reintroducing the wrong class fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:48:39 +01:00
librelad
34512f7b28 setup: rebuild the wizard's Backups step around the locations that exist
The step asked one question — pick a destination, or "not now" — while the
system underneath already had a full location model: eight backend types, per
location engine, path mode, credentials and retention, and a generated
locations.json carrying all of it. None of that was reachable during setup, so a
second destination, or even seeing where the first one points, meant finding the
Backup page afterwards.

Now it mirrors the Storage step — the choice above, the list below:

  Backups        Automatic — daily, on a schedule  |  Manual
  Destinations   Local disk [default] /libreportal-backups/1   [Edit]
                 + Add destination

Automatic/Manual needed a setting, because there was no off switch:
crontabSetupBackupScheduler installed the entry unconditionally. CFG_BACKUP_MODE
is explicit rather than overloading "empty schedule", so it reads properly in
the config editor too, and Manual REMOVES an entry that is already installed
rather than merely declining to add one — otherwise answering Manual changes
nothing. The schedule itself is left alone, so switching back restores the time
the user picked.

Destinations are seeded from locations.json, so the default one is shown and
editable instead of being discovered later, and only entries the user actually
added or changed are submitted. A destination on the same disk as the app data
says so on the card rather than in a paragraph under the step.

Remote destinations are what the secret channel was for. The wizard payload is
base64'd into a task's command string and tasks are recorded world-readable, so
a password is POSTed to /api/setup/secret, which writes it where only the
manager can read it and returns an opaque reference; the reference travels in
the payload and setup_apply redeems it once, at the write. A reference that
cannot be redeemed leaves the password alone and says so, rather than blanking
it.

Verified in the browser on a clean install: the step renders both modes, lists
the existing destination at its resolved path, and the add dialog swaps between
local and remote fields. scripts/dev/lp-backup-setup-test covers the apply side,
including that what reaches the config is the secret and never the reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:20:23 +01:00
librelad
0aa92e128e webui: a one-shot channel for secrets, instead of the command line
A password typed in the WebUI has to reach the host, and both existing routes
leak it. As part of a task's command string it lands in
frontend/data/tasks/*.json — 0644, inside a world-readable directory — and is
visible in `ps` while the task runs; as a plain file there it is either
world-readable at 0644 or unreadable by the manager at 0640. Verified still true
on a clean install. A backup repository password sent that way is the key to
every backup the user has, readable by any local account.

libreportal-ownership gains `secret-dir`: the mirror of _webui_bind_access.
That one makes manager-owned config readable by the container; this makes a
container-written file readable by the MANAGER. The directory is
<container>:<manager> mode 2730 — setgid so each file inherits the manager's
group, the container writes it 0640, and 0730 leaves the directory unlistable
because the manager is handed a filename rather than going looking. Group rwx
is what lets it unlink after reading.

The WebUI then sends a REFERENCE ("secret:<id>") wherever it used to send the
value, and configUpdateBatch redeems it at the last moment before the write.
That is the single point every config write from the WebUI passes through, so
this covers every password field rather than only the backup ones — which is
what docs/roadmap/first-run-restore.md §4.1 asked for. A reference that cannot
be redeemed leaves the field unchanged rather than blanking it.

Verified on a live install: the container drops a secret, the manager applies it
by reference, the file is unlinked, `nobody` can neither read nor list it, and a
second redemption of the same reference fails.

footprint_version 9 -> 10 (root-owned helper changed).

Also fixes a block of constructor initialisations I spliced into the middle of
renderStorageChoices in aa44e0b: on a single-drive box — the case in the
screenshot that prompted this — rendering the Storage step silently reset
backupDest and cleared the import selections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:48:03 +01:00
librelad
78bc20b8ac setup: surface the per-app storage choice in the App Center
Choosing a drive worked from the CLI but was invisible in the WebUI, for three
separate reasons, each of which hid the next:

  * the config editor only renders fields listed in apps-field-mappings.json,
    and STORAGE was not one — so no amount of correct data made it appear. Added
    there, in General, with its choices built from the locations registered at
    generate time (unlike every other select here, they are not knowable
    statically).

  * app TEMPLATES ship "[default:Primary]", and templates are what the install
    form reads for an app that is not installed yet — precisely the app whose
    form needs to show which drives exist. storageSyncAllAppComments now covers
    templates, and is finally called from a regen path: it was written for one
    and never wired in, so every option list was frozen at install time and
    adding a drive made it selectable nowhere.

  * storageLocationName resolved a name only from an in-scope
    CFG_STORAGE_LOC_<id>_NAME and otherwise fell back to the bare id. That name
    is the value CFG_<APP>_STORAGE takes, so the generated dropdown offered
    "location-1" as both label and value — a choice that does not resolve. Read
    it from the location's own config when the variable is not in scope.

Then the control rendered but sat blank. Config values are the raw right-hand
side of "KEY=value   # comment"; almost all are stored without a comment, but a
field whose comment is regenerated keeps one — CFG_<APP>_STORAGE records the
location it currently resolves to. updateConfigForm assigned that whole string
to the field, which for a <select> matches no option, sets selectedIndex to -1
and renders empty: an app on a second disk read as "nothing configured", or
after a partial fix as "Primary". Normalise once where the config enters the
form, and never assign a select a value none of its options carry.

Verified in the App Center: authelia, installed on disk1, shows
"disk1 (/mnt/lptest1/apps)" selected, with Primary/disk1/disk2 offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:46:36 +01:00
librelad
7ed8539af7 setup: stop calling the app-data drive "System disk" when it is not
The wizard's Storage step builds its first entry from primaryRoot() — the
app-data root — and labelled it "System disk". On a default install those are
the same drive and the name is honest. Installed with --containers-dir on its
own disk they are not, and the step then showed the DATA drive's size under the
system disk's name while the actual system disk never appeared in the list.

Seen on a matrix case-2 install (apps on a 29.4G test disk, system on a 912G
root): "System disk — 26.7G free of 29.4G".

The generator now reports whether that root is really on the OS disk
(is_os_disk, by st_dev against /), and the wizard labels it from that: "System
disk" when they coincide, otherwise the mount point. The "system" badge stays —
it marks the default location, which is still what it is.

Also add lp-shot --token / --cookie-js. A screenshot answers "does it render";
"does this wizard step work" needs clicking, which needs a real browser, which
needs the session lp-shot already knows how to mint from the stored jwtSecret.
This bug was found that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:35:16 +01:00
librelad
aa44e0b542 feat(setup): Import step — bring apps in from .lpapp files by path
Adds the wizard step for importing existing apps, so the common case is
answerable in the WebUI rather than only from a terminal.

Path-based, not upload, and that is the design rather than a shortcut. A
.lpapp is a plain tarball and the file is already on the server, so
nothing secret crosses into the browser — which is exactly why this can
live in the WebUI when the encrypted-repository restore cannot (§4.1).
Accepts a single file or a folder of them.

Check first, then accept: the step enqueues `app import-check --publish`,
polls the document it writes, and renders one row per file with its
verdict — ready, a warning (its old storage location is gone, so it will
land on the default), or a refusal (already installed, no longer shipped,
will not fit). Refused rows are shown greyed with the reason rather than
hidden, and cannot be selected.

setupApplyConfig re-runs appImport's own checks rather than trusting the
payload: the machine can change between the check and the apply, and the
list arrives from a browser.

The backend route shell-quotes the path — it reaches a command line and
is user input.

Verified: the step renders as "Step 6 of 7", and the underlying check was
proven against real .lpapp files (correct app name from the tar, size
from the manifest, warning for a missing storage location, refusals for
an already-installed app and a non-export).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:10:13 +01:00
librelad
ef60cce98a feat(app): portable .lpapp export and import — phase 4
`libreportal app export <app>` writes one app to a single file;
`libreportal app import <file>` installs it here. This is the thing the
original request described as "upload or navigate to the backup file" — a
restic repository is not a file, but the want behind the phrasing is real.

The format is deliberately boring: gzipped tar of the app directory with
its .libreportal-manifest.json at the root. That manifest already records
size, images, volumes, databases and storage location, so import reuses
the phase-3 checks for free — refusing an app this version no longer
ships, or one that will not fit, before unpacking anything.

Export stops the app first. A tar of a running Postgres is a corrupt
Postgres, and a file that looks fine until you restore it is worse than a
refusal. tar runs as the owning user with --numeric-owner so container
sub-UIDs survive the round trip instead of being remapped through this
machine's /etc/passwd.

Import re-runs the normal install pipeline after unpacking, because the
compose still carries the SOURCE machine's ports, IPs and domains — that
pipeline is what re-allocates them here, and migrateUrlRewrite fixes the
host-bound CFG_* fields.

Documented throughout as a courier format, not a backup: no history, no
retention, no encryption. Importing under a different name is refused
outright rather than half-working — the CFG_<APP>_* namespace and compose
identities would all need rewriting, and `instance create` already
answers "a second copy".

Fixes a bug this surfaced: _appDirIntended did an indirect expansion on
CFG_<SLUG>_STORAGE without checking <SLUG> can be a variable name, so a
hyphenated or mistyped app name emitted "invalid variable name" and then
reported the misleading "storage location is not mounted" for an app that
simply did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:08:41 +01:00
librelad
15ca10530d feat(setup): Backups step — phase 1 of first-run restore
Nothing prompted anyone to configure backups, so the people most likely
to need a restore were the least likely to have one. The wizard now asks,
once, with the drives it already scanned as the options.

Three messages, because the honest answer differs by choice:

  declined      nothing is protected until you set it up
  same drive    still covers deletion, a bad update and ransomware — not
                this disk failing, since the data and its only copy go
                together
  another drive the repository is encrypted; write the password down
                somewhere other than this machine

That last one matters more than it reads. An encrypted repository cannot
be opened with anything stored inside itself, and the location password
lives in the system config, which is inside the backup. On a rebuilt
machine the user must supply it by hand — so the wizard says so up front
rather than letting them discover it during a restore.

The password is deliberately NOT echoed by setupApplyConfig: task output
is logged, and a secret in a log is a secret you have to treat as leaked.
It is shown on the Backup page, which is what the wizard tells the user.

locationAdd creates a location disabled, so the applier enables it and
runs engineInitLocation — an un-initialised destination silently backs up
nothing, which is the worst possible way to have "configured backups".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:11:36 +01:00
librelad
6fd6587714 feat(setup): selections above, drives below, and both roots choosable
Reworks the Storage step: two labelled choices with tooltips at the top —
LibrePortal and New apps — and the drive list underneath as reference.

The drive cards lose their checkboxes. Choosing a drive in a dropdown IS
the request to register it, so a separate tick was a second way to say the
same thing, and the way you end up with a drive ticked that nobody
selected. Cards are now informational plus Details.

Both dropdowns only render when there is a second drive; with one disk
both answers are forced and a pair of selects showing one option each is
furniture.

Moving LibrePortal's own tree cannot be a WebUI action. It re-bakes the
six root-owned helpers, the systemd unit and the WebUI's own bind-mounts
— real root, not the scoped sudo the manager holds. A helper that re-baked
the other helpers from a manager-supplied path would hand the manager
exactly the trust boundary those helpers exist to defend. So picking a
different disk for LibrePortal surfaces the root command to run rather
than pretending the wizard can do it; the payload carries the choice so
the finish screen can repeat it. libreportal-relocate follows.

Also drops "itself" from the installer's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:39:25 +01:00
librelad
3034eaf4c7 feat(install): choose system and app-data disks independently; theme the dropdown
Two fixes.

The wizard's "new apps store their data on" dropdown was a bare native
<select>. The OS draws that popup and ignores our CSS, which is why it
came out as stock white chrome — the WebUI already solves this with
custom-select.js, which enhances any select.form-control into a themed
button and list. It just needed the class.

And the installer now asks for the two roots independently rather than
only app data. I had argued one question was simpler, and for a desktop
it is — the control plane is ~20 MB and moving it gains nothing. But on a
small board with an 8 GB eMMC and a USB SSD you want both moved, and
there was no way to say so without knowing the flags exist. Still one
disk list and two short questions; each is skipped if its flag was
already passed.

Fixed a bug the test caught immediately: _initAskDisk returns the chosen
path on stdout, and it was printing the prompt there too, so the question
text became part of the answer — the system root ended up named after its
own prompt. Prompts go to stderr now, stdout is the return channel.

Verified every combination under a pty: both default, apps only, both
moved, system only, and invalid-then-valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:48:55 +01:00
librelad
49c463a996 feat(storage): a default location, so one choice places every future app
Per-app placement worked but had no default: a box with a big second disk
meant setting CFG_<APP>_STORAGE on every app individually. CFG_STORAGE_DEFAULT
fixes that, and the wizard asks for it in one line.

CFG_<APP>_STORAGE now has three states rather than two, and the third is
the point:

  <name>    this app goes there, whatever the default says
  primary   this app goes on the install-time root, explicitly
  default   no opinion — follow CFG_STORAGE_DEFAULT

Templates ship "default", so the setting reaches every app without
touching 37 configs, while an app that was deliberately placed keeps its
placement. "primary" is new, and needed: without it there was no way to
say "keep this one on the system disk" once the global default moved.

A default naming a location that has since been removed falls back to the
primary root rather than refusing — a disk that got unregistered must not
make apps un-installable.

The wizard asks only once a second drive is ticked; with nothing ticked
there is one possible answer and a control would be furniture. It sets a
default, not a placement, and the value stored is the location NAME, so it
survives the disk being remounted elsewhere.

scripts/dev/lp-storage-default-test covers all three states plus the
removed-location fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:31:13 +01:00
librelad
731b787ce5 feat(setup): drop the system disk's checkbox, group the rest under a divider
The system disk rendered as a checked-and-disabled checkbox, which reads
as "a choice that is locked". It isn't a choice at all — apps fall back
to it whatever happens — so it now carries no control, and is a <div>
rather than a <label> since there is nothing to label. A spacer keeps its
text aligned with the real checkboxes below.

An "Additional drives" divider separates the two kinds of row: above it
is where apps go regardless, below it is what you can opt into. It only
renders when there is something to divide, so the single-disk case is
still one card and a line of text.

Verified both states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:13:08 +01:00
librelad
6a8536fb1b fix(setup): fill the capacity bar with used space, drop the footer note
I had the bar filling with FREE space. It was internally consistent with
the text next to it and completely wrong in practice: every file manager
fills a capacity bar with USED space, so a nearly-empty disk rendered as
a nearly-full bar and read as "this drive is full".

Windows Explorer is the reference for exactly this pairing — a used-fill
bar beside "808 GB free of 912 GB" — so the card now matches it. Amber
above 75% used, red above 90%.

The cosmetic problem that led me to invert it (a low-usage bar looking
like a broken widget rather than an almost-empty one) was the track's
contrast, not the direction. Fixed where it belonged, in CSS.

Also drops the "Drives that can't hold app data are greyed out" line: the
cards and their badges already carry that, so it was a standing sentence
explaining something visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:06:40 +01:00
librelad
e101764085 feat(setup): drop the filesystem type, show capacity as free-of-total + a bar
The card said "911.9G · 808.4G free · ext4". The filesystem type is a
Details row, not something you choose a drive on, so it goes.

On percentage vs size: which one matters depends on the question. This
step asks "will my data fit?", and absolute free space is what decides
that — a 4 GB disk that is 89% free is still useless for a media library.
Percentage answers "is this filling up?", a health signal rather than a
placement one. So the text carries the magnitude ("808.4G free of 911.9G")
and a thin bar carries the proportion, which is what the eye reads
fastest, with no second number competing with the first.

The bar fills with FREE space, not used. Filling by usage made a healthy
7%-full disk render as an almost-empty track that read as a broken widget
— and it pointed the opposite way to the text beside it. Filled = room to
spare, draining = filling up, matching the words. It turns amber below
25% free and red below 10%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:38:14 +01:00
librelad
3b602ddb53 style(setup): space the drive badge from the facts that follow it
The badge had margin-left only, so on a one-line card the pill sat flush
against the drive size — "default 911.9G". Symmetric margin now, since
text follows it on the same line as well as preceding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:31:55 +01:00
librelad
6d31da917f feat(setup): move the Storage explainer into a "?" tooltip
The step opened with a two-line paragraph explaining what storage
locations are. Every other field in the wizard puts that kind of
background behind a "?" — same markup, same component — so this now does
too, and the step is a title, two one-line drive cards, and the buttons.

The footer line was repeating the first half of that paragraph. It now
carries only what is actionable while choosing: that a greyed drive has
its reason in Details, or that no other drives were found.

Verified the bubble renders with the full text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:25:54 +01:00
librelad
d8c9f2486b feat(setup): show the system disk, and slim the drive cards to one line
Three changes to the Storage step.

The system disk is now a first-class entry — pinned first, ticked, and
locked, since apps fall back to it and it therefore cannot be deselected.
Its Details work like any other drive's, which is the whole point on a
single-disk box: the step now answers "where does my data actually go?"
instead of being skipped and answering nothing. The step is consequently
unconditional; the note changes to explain that no other drives were
found rather than the step vanishing.

The system entry is excluded from the submitted payload — it is already
the primary root, and asking the helper to register it would (correctly)
be refused for nesting.

Cards are one line again. Listing every warning under each drive pushed
them to three lines and made the step tall for no gain: the badge already
carries severity and Details carries the explanation. The note now says
to open Details for the reason rather than claiming it is on the card.

Badge colours were dark-on-light, which against the wizard's mid-blue
glass read as muddy grey — the "needs care" pill in particular. Switched
to light-on-dark, legible without shouting over the drive name.

Verified with lp-shot in both states: system disk alone, and system disk
plus a second drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:19:40 +01:00
librelad
e71fc15f38 fix(setup): use the shared eo-modal for drive details
The details popup was a hand-rolled overlay instead of openEoModal, which
every other modal in the WebUI uses. Two things were wrong with that.

It was appended to the wizard container rather than document.body. The
wizard has a backdrop-filter, which makes it a containing block, so the
modal's position:fixed resolved against the wizard instead of the
viewport — the backdrop covered part of the screen and the panel sat
off-centre.

And once switched to eo-modal it still didn't appear at all: .eo-modal is
z-index 1100 while the wizard overlay is 9999, so it rendered behind the
wizard and the Details button looked inert. Raised only under
body.setup-wizard-open, so no other overlay's stacking is touched — the
wizard is the exceptional full-screen surface, so it is the wizard's
stylesheet that declares modals must sit above it.

The bespoke modal chrome is gone from setup-wizard.css; what remains is
the storage-specific content (spec table, checks list, fstab box) that
renders inside .eo-modal-body. Header, sections, backdrop, Escape,
click-outside and the footer action now all come from the shared helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:57:57 +01:00
librelad
7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00
librelad
f5238f21ca feat(setup): wire storage data into regen, split candidate warnings
webuiGenerateStorageCandidates now runs as part of webuiSystemUpdate, so
frontend/data/storage.json exists without anyone remembering to generate
it — the wizard reads it to decide whether its Storage step appears, and
the Disks view reads the same file, so the two can never disagree.

Warnings arrive from the shell joined with "; ". Rendering that verbatim
produced one run-on paragraph that buried the fstab line the user is
supposed to copy, so the card splits them back onto separate lines.

Verified on the live install with lp-shot: with one filesystem the wizard
shows "Step 1 of 4" and the Storage step is correctly absent; with a
second filesystem attached it becomes "Step 4 of 5" with the drive
carrying a "needs care" badge and both warnings legible. That also
exercises the visible-step mapping in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:14:56 +01:00
librelad
cea653f67b feat(setup): Storage step in the first-run wizard
Phase 3 of docs/roadmap/storage-locations.md.

The step appears only when the candidate scan finds a filesystem
LibrePortal isn't already using, so the single-disk case — which is most
boxes — is completely unchanged. It sits before Recommended because a
location has to exist before an app can be placed on it.

Supporting two conditional steps meant the wizard could no longer treat
'position in the DOM' and 'step index' as the same number: Metrics was
advanced-only and got away with 'length minus one', but a step hidden in
the MIDDLE leaves a gap. Navigation, progress, validation and submit now
all run off _visibleSteps(), and section matching is by data-step rather
than DOM position.

Unusable candidates render greyed WITH the reason rather than being
filtered out — 'why isn't my drive listed?' is a support burden, and
'exFAT can't store file ownership' is actionable. The step is skipped
only when nothing usable was found at all.

What the wizard sends is a request, not an instruction: setupApplyConfig
feeds each path through storageAdd, so the fitness checks and the root
helper's admission rules both re-run regardless of what arrived in the
payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:45:10 +01:00
librelad
26e98698d8 feat(storage): per-app placement, app move, and the READONLY marker
Phase 2 and 4 of docs/roadmap/storage-locations.md. Apps can now be
placed on a location and moved between them.

CFG_<APP>_STORAGE lands in all 37 app templates, holding a location NAME
rather than a path: names survive a migrate to a host with different
disks, paths do not. The 11 infrastructure apps that other apps reach by
literal path (traefik, prometheus, grafana, adguard, gluetun, crowdsec,
headscale, dashy, pihole, unbound, wireguard) are pinned. libreportal
itself never gets the key — it is pinned structurally by webuiDir.

Pinning needed no second config key. "Pinned" is not a fact about a value,
it is a statement about whether the field may be edited, so it goes in the
comment beside **ADVANCED** and **DEV** as **READONLY**, and the field
factory renders those disabled. That marker earns its keep beyond this
feature: derived fields already warned in prose that editing them does
nothing (crowdsec.config:72) next to a perfectly editable input.

storage_app_config.sh keeps the comment honest — it carries the resolved
path for hand-recovery and regenerates the dropdown from the registry, but
only writes when something actually changed, since the app .config is
user-editable and lives in the container-owned tree.

app move stops the app (a live copy of a running Postgres is a corrupt
copy), snapshots it, copies, verifies, and only then removes the source.
The copy runs in libreportal-ownership because it must: app data holds
rootless sub-UID files the manager can neither read nor recreate.
Verified against two real ext4 filesystems that a cross-device move
preserves uid 231141 and the payload, and that the source survives every
refusal path — unregistered destination, the WebUI app, a traversal in the
app name, and an occupied destination.

Task titles registered in both tables, with a specific rule so a move
renders as "Nextcloud - Move to bigdisk" rather than the generic fallback
dropping the destination. lp-task-names could not be run to confirm — it
borrows the WebUI container's node and no containers are running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:37:35 +01:00
librelad
8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00
librelad
0e6eb841bd fix(webui): refresh the Instances bar when an instance task completes
The post-task handler only repainted the app detail page when the finished
task's app WAS the app on screen, and only ever re-rendered that same slug.
An instance removal is neither: it is dispatched for the instance, while the
list that changed — the Instances bar — is drawn on the parent and on every
sibling. Removing an instance from a family page left its pill sitting there.

Now the family (INSTANCE_OF) is read before the apps.json reload — a removed
instance is gone from it afterwards — and the page ON SCREEN is repainted
whenever it belongs to the same family as the completed task, so a pill
appears on create and disappears on remove without a manual refresh.

Also handles the page whose app no longer exists: an instance removal deletes
the app outright (a plain uninstall only flips it to not-installed), and the
removal flow parks the user on the instance's own Tasks tab, so re-rendering
printed "App not found". Land on the type it was an instance of instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:48:18 +01:00
librelad
facf764c4b feat(webui): bulk selection on app tasks, updates overview, and app backups
The /tasks page's right-side tick + dynamic Select all / Clear All ⇄
Delete Selected layout now covers the other three management surfaces:

- App detail → Tasks tab: filter bar gains the Clear All button and
  master tick; Clear All there scopes to that app's tasks only. The
  selection set is resolved through window.tasksManager everywhere —
  TasksManager is constructed in several places, and ticks previously
  landed on one instance while Delete Selected read another's empty set.
- Apps overview → Updates: the header's Update all button now morphs to
  Update Selected (N) + Clear in place as rows are ticked, replacing the
  separate selection bar between toolbar and list.
- App detail → Backups: each snapshot row gains Delete + a right-side
  tick; a toolbar atop the list morphs Delete All ⇄ Delete Selected (N).
  The whole selection rides in ONE task (delete <app> 1:a,2:b,…) since
  the backup surfaces hold one task per subject at a time.
- CLI: backup app delete accepts comma-separated <idx>:<snap> pairs, and
  both delete and delete_all now regenerate the WebUI backup JSON so
  deleted snapshots leave the screen instead of lingering until the next
  backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:59:19 +01:00
librelad
dd68c04fec fix(updater): stop showing ghost rows for uninstalled apps
Matrix was uninstalled and the Updates tab kept listing it as up to
date. Not an instance problem — updates.json and cves.json are
scan-time snapshots on a 30-minute cadence, and nothing rewrote them at
uninstall, so any removed app haunted every updater surface until the
next scan happened to run. The backend was never wrong: the DB, the
apps data and the app's own page all said uninstalled within seconds.

Fixed at both ends. Uninstall now deletes the app's rows from both
generated files, surgically — a full rescan re-runs CVE checks against
every image and has no place inside an uninstall. And the updater's
merge drops any row whose app window.apps does not list as installed,
which covers every other way the snapshot can go stale (a crashed
uninstall, a hand-edited file, the next bug). The filter only applies
when the installed list has actually loaded, preserving the page's
degrade-gracefully contract when it has not.

The stale Matrix rows on this install were purged the same surgical
way; the tab now shows 14 rows with the merge still intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:50:11 +01:00
librelad
b3b9f9a18b fix(webui): stop the loader double-loading scripts, and the sidebar noise
Two recurring console errors, each masking real ones.

"Identifier 'BackupAppCard' has already been declared": the app has two
script loaders that could not see each other's work. The boot-time
system loader injects tags with NO id and dedupes by script[src]; the
SPA's loadScript injects with an id and deduped ONLY by that id. So a
file both of them load — backup-app-card.js is on the boot list AND in
the overview Backups tab's asset chain — loaded twice whenever the SPA
loaded second: its getElementById check cannot see an id-less tag. The
class redeclared with an uncaught SyntaxError. The blindness was
one-directional, which is why it looked sporadic. spaClean.loadScript
now also matches by src, closing the asymmetry for every shared file,
not just this one.

"ConfigSidebar: config-categories-list element not found", three to
four times per navigation: renderConfig populated the admin sidebar
unconditionally, but the backup center's Configuration tab EMBEDS that
renderer inside its own pane (renderConfig('backup', <target>)), where
the sidebar element legitimately does not exist — and the refresh
coordinator re-renders that tab on every task event. Populating is now
skipped when a target is passed: an embedded render does not own the
admin shell. The error stays for target-less renders, where a missing
sidebar genuinely is a broken shell.

Verified in a live session: repeated sweeps across /tasks, the overview
Backups tab (Configuration sub-tab included) and a per-app Backups page
produce zero new occurrences of either error — confirmed against a
live console buffer via a sentinel, since the buffer retains the
pre-fix history and re-reading it can look like recurrence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:28:36 +01:00
librelad
429e750266 feat(webui): backup, restore and delete buttons show their task running
Same treatment the updater buttons got. Confirming a backup produced a
toast and a blind 1.5-second refresh; the tile's "Back up" pill stayed
armed and nothing said work was happening. Restore and Delete on a
snapshot row behaved the same.

runTask now derives the SUBJECTS a command holds from the command
itself: an app backup busies that app's tile, `backup system` the
system tile, and `backup all` busies every tile at once — one task,
honest feedback everywhere it acts. Restore and delete also parse the
exact snapshot out of the command, so only the clicked row's button
spins while the app's other snapshot buttons merely disable: the
spinner marks the action running, not the ones waiting on it.

Starting a second task on a busy subject is refused with a notice — a
second restore of the same app queued behind the first is a footgun,
not a feature.

The face survives re-renders: the refresh coordinator repaints this
page on every task event, so the tile and row renderers consult the
busy map rather than relying on the instant DOM patch alone. Cleared on
the task's terminal state, before the coordinator's debounced repaint
lands, so that repaint shows the finished state. The leak backstop is
two hours — a big app's backup is legitimately slow.

Verified end to end against the live install with two real backups:
the pill flips to "Backing up…" (disabled, spinner) in the same tick as
the confirm click, holds through the run, and restores within a second
of the task completing, with the busy map empty. Subject derivation
unit-tested across all five command shapes, including `backup all`
fanning out to every tile and snapshot-level targeting for restore and
delete. Reuses the updater's .btn-spin — both stylesheets are global,
so busy looks the same everywhere in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:14:11 +01:00
librelad
156c7fcc08 fix(webui): stop the styled tooltip doubling with the browser's own
Four rules rendered their bubble with `content: attr(title)` while leaving the
title attribute in place, so the browser drew its native tooltip over the styled
one — two bubbles, offset, one of them unthemed. That is the overlap.

setup-wizard.css already had it right with attr(data-tip). Everything else now
matches: the text moved to data-tip, which the browser does not render, and
aria-label carries it for assistive tech. 24 emitters migrated across 9 files,
including two that assign the text at runtime — those set both attributes so
neither goes stale.

The same sweep found the bubble was defined twice globally. core/forms/css had
the plain one; port-manager.css carried a nicer bordered, wrapping version
commented "Enhanced tooltips for main config help icons" — which never applied
to them, because forms.css loads afterwards at equal specificity and won. So
every help icon outside the port manager had been getting the plain bubble, with
`white-space: nowrap` and no max-width: a sentence-length tooltip rendered as one
unbroken line running past its own background. The wrapping version is now the
one in forms.css, the shadowed copy is gone, and the .port-manager-scoped rule
stays.

Verified on Bookstack's config page: 26 help icons, none carrying a title, all
carrying data-tip, and hovering one flips ::after content from `none` to the text
with a single themed bubble on screen.

No port-descriptor tooltip, deliberately. PORT_N fields are replaced at runtime
by the port manager, whose hidePortFieldLabels() sets the field's help icon to
display:none — anything put there is unreachable, confirmed in the DOM. The
format is already documented column by column by the port manager's own 13 help
icons, several in more detail than a single descriptor string could manage.
2026-08-21 00:01:18 +01:00
librelad
785df3dcd8 fix(tasks): name upgrade tasks, humanise dead instance slugs, keep icons
Three visible faults on the Tasks page, one shared root.

Upgrade tasks rendered as their raw command — "libreportal updater
upgrade rocketchat 8.7.1" beside properly named neighbours. The title
table had rows for updater check/apply/apply-all/rollback and none for
upgrade, because the upgrade command is assembled in task-actions.js
rather than task-commands.js — and lp-task-names, the guard built to
catch exactly this, only read task-commands.js. It certified 16 commands
and reported that as the whole surface; the surface was 29. The guard
now reads both dispatch sites (JS ${expr} interpolations become sample
placeholders; commented-out prose mentioning commands in backticks is
skipped, or it reports fictional commands), and all 29 pass.

A removed instance's tasks outlive it, and its slug rendered as a tech
identifier: "Bookstack_uitest - Remove Instance". getAppDisplayName
cannot help — it capitalises as its own fallback, so unknown is
indistinguishable from known-and-plain. The formatter now does the same
membership test the helper uses internally: slug absent from
window.apps, prefix before the underscore present -> render the way
live instances are shown, "Bookstack · uitest".

Same story for the icon: bookstack_uitest.svg is deleted with the
instance, and onerror="display:none" left a bare gap in the row. Now a
fallback chain — the TYPE's icon (which survives), then the LibrePortal
logo. Verified live: the dead instance's rows show bookstack.svg with
the fallback marker set, everything else keeps its own icon.

Verified in a real browser session — full render, zero console errors.
The 'add' verb also joins the app-action map so "Add Application" is
deliberate wording rather than the blind "<Verb> Application" compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:57:30 +01:00