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>
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>
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>
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.
Replaces the follow-the-task navigation from the previous commit —
carrying the user to the tasks page and back was feedback by relocation.
This is feedback where the click happened: the button becomes
"Updating…" with a spinner and stays disabled until the task reaches a
terminal state, then the data refetches and the row repaints with the
result. Update, Update all / selected, the stepped Upgrade and Roll back
all get it (verbs keep their own labels: Upgrading…, Rolling back…).
Two paths keep the face honest. A DOM patch flips the buttons the moment
the click lands — no waiting for a render pass — stashing the original
face on the element so a failed dispatch can restore it. And every
renderer now builds these buttons through one busy-aware helper, so a
repaint landing MID-task (the auto-refresh poll, a filter change)
reconstructs the spinner instead of silently re-enabling the button.
Sibling actions are held while a task runs: Roll back on an app that is
mid-update is disabled — without a spinner, which marks the action that
is running, not the ones waiting on it. Entry points also guard on the
inflight set, so a keyboard-triggered duplicate is inert.
Correlation is by task id (routeAction resolves to the created task), so
another task finishing cannot end this button's busy state early. Only
completed/failed/cancelled end it; listeners are removed on the first
terminal event, with a 30-minute timeout so a task that never reports
terminal cannot pin a spinner forever. cursor is "progress", not
"not-allowed" — the work is happening, the button is not refusing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes to the Updates tab, all reported from the same screenshot.
Upgrade is green and just says "Upgrade". It was accent-styled and carried the
target version, which duplicated the confirmation it opens — the dialog already
names the version, so the button only had to name the action. Green also stops
it reading as the same control as the cyan Update beside it.
The picker moved from the head of the row to the tail, matching the Tasks list.
It used to lead with an `ov-pick-empty` spacer holding the column open, so with
nothing updatable every row carried a left gutter for a checkbox that was never
coming — the gap in the report. Unselectable rows now render no picker at all.
Select all joins the toolbar, right-aligned above that column, again mirroring
Tasks. It acts on the selectable rows in view, so a narrowing filter chip cannot
leave it quietly picking rows nobody can see.
The confirmation dialog takes an optional icon and the upgrade flow passes the
app's, so it leads with the same icon-in-holder the instance modals use. Styled
in base.css rather than borrowing .app-card-icon: this dialog is global and
appears on pages that never load the apps stylesheet. Its title now uses the
display name too — "Upgrade matrix to…" beside the Matrix logo read as a
different thing.
Verified against the live WebUI: green Upgrade with the gutter gone; Select all
and the trailing checkboxes with two apps temporarily flagged (data restored
byte-identical after); the master toggling 0 -> 2 -> 0; and the dialog showing
matrix.svg under the title "Upgrade Matrix to v1.159.0?".
The button drew its text from --status-danger, which is tuned as a FILL
colour — the confirm button in the remove modal sits white text on top of
it, and there it is correct. Reused as text on the app's own background
it measures 3.79:1 on nebula, 3.84 on dark-blue and 3.95 on the example
theme, all under the 4.5:1 AA floor, which is why the control read as
barely there. Light is the only theme where the same value passes, at
4.53:1, so brightening it globally would have fixed three themes by
breaking the fourth.
Two jobs, two values. --status-danger-text is now defined per theme:
#f87171 on the dark ones (6.29-6.47:1) and #c92a37 on light (5.43:1, up
from 4.53). #f87171 is not a new invention — it is the red the CVE
severity chips already use for exactly this job, so danger-as-text now
looks the same wherever it appears.
The border follows the text so the two stay coherent, and both fall back
to --status-danger, leaving any theme without the token no worse off
than before. The filled modal button is deliberately untouched: white on
#dc3545 was never the problem.
Other danger-as-text call sites exist (loading screen, network notifier)
and would likely benefit, but they render on their own backgrounds
rather than the app surface, so they are not assumed to share the
measurement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three rows read "CRITICAL · V1.159.0 AVAILABLE" and offered no button, so the
Updates tab looked broken — reported twice as "I can't see an update button".
Nothing was broken: Update is gated on update_available, which is false for all
14 apps here. Those three have newer_version instead, whose action is `upgrade`,
and that button existed only inside the row detail.
Hiding it was justified as wanting more care than a row button. It does not buy
that. updater-page's upgrade() already opens a confirmation spelling out the
per-step recovery snapshot, the rollback-and-stop behaviour and the likely
duration, and nothing runs until it is accepted. So the care is in the dialog
either way; hiding the button only hid the action behind a Details click nobody
had a reason to make.
The row now shows whichever action applies — Update (primary) for a same-tag
image, Upgrade to <version> (secondary) for a new release line — keeping the two
visibly different. The handler was already wired for `upgrade`; only the button
was missing. Update all and the multi-select tickboxes stay gated on
update_available, since walking release lines in bulk is a different risk.
Verified against the live WebUI: Matrix, Rocket.Chat and Mattermost now carry
Upgrade to v1.159.0 / 8.7.1 / 11.10 beside Details.
Task titles come from one declarative table in formatCommandForUser, whose final
fallback is the raw command string. Five of the sixteen command shapes the WebUI
dispatches had no row and hit that fallback, so they showed as
"libreportal instance remove bookstack_work" where a backup shows as
"Bookstack - Create Backup". Cross-checked every shape in task-commands.js
against the real formatter rather than fixing only the reported one:
instance create -> Bookstack - New Instance (work)
instance remove -> Bookstack · work - Remove Instance
system status -> LibrePortal - System Status
system update -> LibrePortal - System Update
system reset -> LibrePortal - Reset System
Instance create is named off the TYPE rather than the new slug: at create time
the instance is not in window.apps yet, so displayName() would fall back to
capitalising the slug and render "Bookstack_work".
Four more were not raw but read badly, because the generic
"<Action> Application" fallback has no entry for the compose verbs: up and down
rendered as "Up Application" and "Down Application". Now Start/Stop Containers,
Reload Application and Check Status. And per-service restart collapsed to
"Restart Application", losing the only thing that distinguished it — it now
names the service.
All seventeen shapes verified through the real formatter: zero fall through.
Instance removal also finishes properly now. It skipped the button lockout a
normal uninstall applies, so the instance's Config/Tools/Backups tabs stayed
live against an app being torn down underneath them; it now makes the same
disableAppButtons call executeUninstall does. And it bounced to the TYPE's
Config page, hiding the very task the click had just started — it now lands on
the removed instance's own Tasks tab.
Verified by driving the WebUI: creating an instance files a task titled
"Bookstack - New Instance (uitest)", removing it lands on
/app/bookstack_uitest/tasks, and disableAppButtons flips all five app tabs from
enabled to disabled. Test instance removed afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updating one app and updating everything were both already there — a
per-row Update button and an Update all in the tab header. What was
missing is the middle: choosing three of eight. Both existing controls
are gated on update_available, so with a fully current fleet the tab
shows neither and reads as though it cannot update anything at all,
which is what prompted this.
Checkboxes appear only on rows that actually have something to apply.
Offering one on a current app would be a choice with no outcome, and
"selected 6, updated 2" is a worse answer than not offering the four.
The column is still reserved on every row, hidden — otherwise the icons
shift left and right as scans land and rows change state.
applySelected filters the chosen names against update_available rather
than trusting them: a selection can outlive the scan that justified it,
and asking to update an app with nothing to apply would spend a snapshot
to achieve nothing. Update all now routes through the same function, so
there is one path, one task, and the same per-app contract — snapshot
first, roll back on failure.
Two details that only show up when you use it. The checkbox sits inside
the row head, which is itself the expander target, so its handler has to
claim the click or picking an app also opens its detail panel. And the
count is refreshed in place instead of re-rendering the tab, because a
re-render rebuilds the checkboxes under the pointer mid-selection.
The bar holds its space at zero selected rather than appearing on the
first tick, which would shift the list down under the cursor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four faults, all in the same 12-column format, all silent.
The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.
The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".
Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.
Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.
Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty chip is a dead end: clicking it can only ever produce "Nothing matches
this filter". Hidden rather than disabled, because a greyed-out chip still
occupies the eye, and "no apps have CVEs" is better said by the row of chips
being short than by a control you are told not to press.
Two exceptions. `all` is never hidden — it is the way back. And the active chip
stays put even at zero: a rescan can empty the filter you are standing in, and
pulling it out from under the selection would leave the list filtered by a chip
that is no longer on screen.
Verified against the running WebUI: Updates sits at 0 here and its chip is gone,
leaving All 14 / Newer 3 / Security 11, with every remaining chip still
selecting correctly. Forcing the filter to `updates` re-shows that chip marked
active, so the zero-but-selected case renders as intended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list rendered in whatever order the updater emitted, so the handful of rows
that actually want a decision sat wherever they happened to land — on a long
list, below the fold, under a wall of "up to date".
Rows are now ranked by what you can DO about them rather than by how alarming
they look, since an app with an Update button is one click from resolved while
an app with only a Details link is not:
0 update available, and the automatic attempt already failed
1 update available
2 a newer release line is published
3 CVEs, but nothing to apply
4 never scanned
5 up to date
Auto-failed leads because that build was tried and rolled back and will NOT
retry itself — it is the only row in the update group that strictly needs a
person. Worst severity orders within a rank, then display name, so the ordering
is total and stable across repaints.
Filtering already existed (All / Updates / Security); this adds the one state
that was unreachable. `newer_version` is deliberately excluded from `updates` —
its action is Upgrade, inside the row detail, not the row's Update button — so
it had no chip of its own despite being the state most likely to need a
decision, because it never resolves on its own: automatic updates only track
the line you are already on. The chip excludes rows already counted under
`updates` so the totals partition the list rather than double-count.
Verified against the running WebUI. With real data the three newer-version rows
lead (critical, critical, high, then alphabetical), followed by the eight
remaining CVE rows and the three clean ones — 3 + 8 matching the Security count
of 11. Temporarily flagging two apps update-available confirmed ranks 0 and 1
take the top, with the auto-failed row above the plain one; that data file was
restored byte-identical afterwards. Every chip was exercised by clicking it:
All 14, Updates 0, Newer 3, Security 11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The instances line had two states where there are three. An app with a
blocker cannot be instanced; an app whose flag is explicitly false was
reviewed and deliberately kept to one — the resolver clients point at,
the auth provider every router points at, the scanner the updater
resolves by a fixed container name; an app with no flag has simply never
been looked at.
Collapsing the middle case into the last reported a decision as an
oversight, and invited someone to "fix" it by flipping the flag. Those
apps now read "Not offered" and point at the reason recorded above the
flag in their config, rather than "not been opted in".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reason spent a full line of every non-instanceable app's page on a
sentence only someone asking "why not" wants. It now shows a short
"Not available" / "Not enabled" with a hover-and-focus target carrying
the technical detail.
It also says more than it could before. The old text knew about one
blocker — a pinned host port — and its own comment conceded the rest was
"left unexplained rather than guessed at, since the remaining blocker
lives in the compose, which the frontend never sees". It does see it:
apps.json carries each app's service list, so the second rule
instance_create.sh enforces (a service or container name with no <app>
prefix, which cannot be made unique per instance) is reproducible here.
Prometheus now says node-exporter and cadvisor rather than nothing.
And it separates two states that read identically before. An app with no
blocker at all is not "unavailable", it is simply not opted in — a
different sentence with a different fix, and the one Matrix was hitting.
Keyboard reachable via tabindex, with aria-label duplicating the tooltip
since screen readers do not reliably surface a title attribute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row head is the click target that expands a row and already set
cursor:pointer, but nothing changed under the pointer — so a clickable row
looked identical to a static one right up until it opened, and the only visual
state was post-click.
The tint is painted by the row rather than the head. .ov-row's `padding: 0`
loses to .updater-row's `padding: 12px 15px` on equal specificity, so the head
is only 34px tall inside a 60px row; filling the head left a floating band with
13px of untinted row above and below it. Painting the row covers the full
height and picks up its 11px radius for free.
Keyed on `:has(> .ov-row-head:hover)` rather than `.ov-row:hover` so the
highlight tracks what is actually clickable: on an expanded row, drifting into
the detail body stops indicating a click target, because there isn't one there.
The head-level rule stays as the fallback for engines without :has() — it still
marks the row, just inset — and the pair is mutually exclusive so the two tints
never stack. :has() is already relied on elsewhere in the WebUI CSS.
Verified against the running WebUI by driving Chrome over CDP and dispatching a
real mouseMoved, since lp-shot cannot hover: the hovered row goes from
rgba(255,255,255,.035) to .086, its neighbour stays at .035, and the head fill
reads transparent so nothing double-tints. Confirmed visually in the capture
too. ov-row-head is emitted only by renderUpdates(), so no other tab changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"06:00-08:00" is unambiguous on paper and ambiguous at a glance — the
strip said updates install "during 06:00–08:00" and left the reader
working out whether that meant morning or night. The window now renders
as 6am–8am. The config stays 24-hour so there is still one canonical way
to type it; only the display spells it out, and anything that is not a
plain HH:MM-HH:MM passes through untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.
Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.
Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.
The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.
Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.
WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.
Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while installing two LAN-only Bookstack instances — both in the same
no-domain path as the previous commit:
- apps-services.json advertised every app at http://localhost:<port>. The
CFG_SERVER_IP override it reads is defined in no config file, so the lookup
always fell through to the "localhost" default — a URL that only resolves for
someone browsing on the server itself. Now falls back to $local_ip_v4, the
same host APP_URL is stamped with.
- traefikManaged was inferred from `access == public`, a stated placeholder.
Public only means the port is published on the host; it says nothing about a
router. It reported true for both new instances despite their compose having
traefik.enable:false. Now read from the port's own traefik column, gated on
the app's domain actually being set — resolved per-app here rather than from
$domain_full, which this generator never populates.
- The "New instance" modal led with bare text. It now shows the type's icon in
the same .app-card-icon holder the grid cards use, so it's visually tied to
the app the user clicked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two display bugs the newer-version work made visible.
A fleet row showed a green "✓ Up to date" directly beside a
"1.159.0 available" chip. Both statements are individually true — you
ARE current on the line you track, and changing lines is a deliberate
act — but a row is a glance, not a place to reconcile two chips that
appear to disagree. The green all-clear now gives way to a neutral
"Newer version" whenever a newer release line exists; the chip still
carries the number and the tooltip still explains the move. The per-app
detail deliberately keeps "up to date" and is left alone: there the
badge arrives with a sentence explaining the distinction and an Upgrade
button, which is what makes it readable.
updaterDisplayVersion preferred the OCI version label unconditionally.
That label is inherited from the vendor's base image unless they
overwrite it, so it can describe the OS rather than the app: mongo:8.0
carries org.opencontainers.image.version=24.04, its Ubuntu base, and
Stoat's row read "24.04 → 8.0 · 02a0cc7" — not a version transition at
all. When the tracked tag is versioned we already hold an authoritative
version, so the label now wins only if the two agree on their leading
number. Keeps nextcloud 34 → 34.0.1, rejects mongo 8.0 vs 24.04, and
leaves rolling tags untouched since the label is the whole point there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance's isolation never needed a domain — its own slug, dir, secrets,
IP and randomly-allocated host port already make two copies independent. But
the routing layer assumed one, so a LAN-only box got a broken instance rather
than a port-served one. Four fixes:
- instanceCreate now rewrites the parent-service column of the cloned config's
PORT_ rows to match the service names it stamps into the compose. That value
is stored as network_resources.parent_service and joined against the
compose-derived service names, so an instance left carrying the TYPE's
service name matched nothing: it rendered in the WebUI with no port, no URL
and no login row despite being up and reachable.
- `instance create --local` (plus a LAN-only toggle in the modal) forces every
port to access=private, traefik=false, for a second copy that should stay
off the domain even when one is configured.
- initializeAppVariables forces the traefik column false when no CFG_DOMAIN_n
is set. Previously a traefik=true port with an empty domain stamped
Host(`app.`) — a trailing-dot host matching nothing — and dragged APP_URL to
https://app. with it, breaking every app that builds its links from APP_URL.
host_setup is blanked for the same reason. The published host port is
untouched; access type, not the traefik flag, gates allocation.
- APP_URL's direct host-port branch now prefers a new $local_ip_v4 (the source
IP for the default route) over $public_ip_v4, which is the WAN address from
an external resolver. LibrePortal never forwards ports, so the WAN address
was unreachable for exactly the LAN/VPN clients that branch serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Locking every tab but Tasks is right for install/restart/backup: those jump
to the task log, so Tasks is the one tab you need. A tool run deliberately
stays where it was launched and brings its result back to Tools — greying
Tools out stranded the user on a tab they could no longer return to.
disableTabs() now takes the tab to leave alone, chosen per task type by
keepTabFor(). Same rule on the page-load path, which also stops yanking a
reload mid-tool-run over to the task log.
Running a tool from the Tools tab raised two full-size toasts around a few
seconds of work — "task started!" as the run began and "task completed!" as
it ended — and then opened the result modal that actually carried the answer.
The started one was stale by the time it was read and the finished one said
what the modal was already showing.
Tool tasks now go through LP_BACKGROUND_TASKS with a new `silent` flag (no
started toast, no finish line), and tools-manager raises a compact
"Running <tool>…" spinner toast for the duration instead, dismissed the
moment the result modal or the user list opens.
A failed list_users now falls through to the result modal too — it has no
account list to open, and the completion toast that used to report the
failure is gone.
The app-scoped Tasks tab never sorted. It rendered straight from
tasksManager.tasks and relied on loadTasks() having ordered it, so any path that
appends after the load — a task arriving from the event bus, a retry, a queue
merge — put that task wherever it happened to land rather than at the top. Sort
where the list is rendered instead of trusting it from three callers away.
Honest note on the reported symptom: a list_users task appearing mid-list could
not be reproduced from the stored records — replaying the sort over all 96 task
files puts the newest tool tasks first. What is demonstrably wrong is the
missing sort above, and a second latent fault it would mask: 8 of those 96
records carry a null createdAt (cron-created backups), and `new Date(null)` is
the epoch, so they sort as if from 1970 rather than as unknown.
Adds window.taskSortTime for that: createdAt when it parses, otherwise the
timestamp already embedded in the task id — the WebUI mints
task_<epoch_ms>_<rand> and the backend task_<epoch_s>_<hex>, distinguishable by
digit count. All three sorts now use it, so the global list, the app list and
the loader agree.
The filter bar is client-side over the already-loaded per-app array, so it is
instant and needs no reload: status chips (built from the statuses actually
present, with counts, so a chip can never return zero) plus a search over the
command and the task id — the id being what a deep link and a log URL both
carry, so pasting one finds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.
Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.
Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.
And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both _titleBlock implementations title-cased the slug themselves instead of
calling getAppDisplayName, so the Tools tab read "Run app-specific actions for
Rocketchat" and Services read "the docker compose services that make up
Speedtest".
getAppDisplayName already resolves a slug to the app's declared title through
window.apps. Using it fixes four apps beyond Rocket.Chat:
rocketchat Rocketchat -> Rocket.Chat
speedtest Speedtest -> LibreSpeed
ipinfo Ipinfo -> IPinfo
libreportal_catalog Libreportal Catalog -> LibrePortal Catalog
The slug casing is kept as the fallback for the window.apps-not-loaded-yet case,
which is what the helper does internally anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tools tab has an interactive modal: when a list_users task completes it
parses the task log for EZ_USER lines and renders one row per account with
reset / promote / delete buttons. All four new apps failed its contract in every
respect, so running List Users produced log text and nothing else.
- The marker is EZ_USER, tab-separated as email, username, roles. Matrix and
Stoat emitted LP_USER in a different field order; Mattermost and Rocket.Chat
emitted no marker at all.
- Matrix and Stoat then consumed their own marker lines in the formatting loop
and printed only the pretty version, so nothing reached the log to parse.
- The row buttons look up tools by id: reset_password, set_admin, delete_user.
The deactivate tools were named deactivate_user / disable_user, so no delete
button rendered.
- Prefill only fills a field named email or username. Rocket.Chat's and Stoat's
identifier field was called user, so a row action would have opened with an
empty box.
- '-' placeholders are truthy, so the modal's `email || username` fallback
picked '-' over the real username for accounts without an email (rocket.cat).
The EZ_USER line now carries an empty string; '-' stays in the readable line.
Mattermost's listing is rebuilt on `mmctl --json`, which carries roles and
delete_at. The text listing has neither, and there is no --system-admin filter
on user list, so every account was reported as a plain user. Two parsing notes
that cost time: mmctl prints status lines both before and after the JSON, so it
needs raw_decode rather than json.loads; and --per-page above 200 makes it emit
a warning line ahead of the payload.
The modal's delete button also stops asserting "Delete user" over whatever the
tool actually does — it takes its label and icon from the tool, because most of
these deactivate and Matrix cannot delete at all.
Verified by replaying the modal's own parser over real tool output: 2 rows for
Matrix, 4 for Mattermost, 3 for Rocket.Chat, with admin and deactivated states
resolving correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each action-board row on /apps/overview already carries a navigation
button (Review / View / Open Backups). The whole row now triggers that
same navigation, so the small button is no longer the only hit target.
Rows opt in via a `nav` descriptor, so only plain go-to-that-tab rows
become clickable — rows with no action, and the "Update all" button
inside the updates row, are unaffected (updater actions are matched
first in the delegated handler, so Update all never double-fires).
The row is not given role="button": nesting the real buttons inside a
button role would break them for assistive tech. The inner button stays
the focusable, keyboard-reachable control; the row is a mouse-only
widening, with a hover state that lifts row and button together. A
whole-row click is suppressed when it ends a text selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Login Details card fell back to the literal 'admin' when an app declared no
user or email key, so speedtest — which has a single CFG_SPEEDTEST_PASSWORD_1
and no user concept at all — advertised "User: admin" to anyone reading its
card. There is no such account; the field was fabricated by the fallback.
Show the username row only when a user or email key actually exists, and the
password row only when a password key does. eoCredList already omits any row
whose value is null, so passing undefined for either half renders just the half
that is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every app with a generated admin password displayed "(not generated)" in its
Login Details. The credential matcher anchored on
^CFG_<APP>_(ADMIN_)?(PASSWORD)$, which stopped matching when generated keys were
given slot numbers — CFG_MATRIX_ADMIN_PASSWORD_1 and friends no longer hit the
regex, so the lookup fell through to its placeholder.
All ten apps carrying an admin password were affected: adguard, authelia,
bookstack, gitea, invidious, matrix, nextcloud, owncloud, pihole and stalwart.
Allow an optional _<n> tail. The anchor is kept otherwise, so a database or
upstream credential still cannot be mistaken for a login — CFG_<APP>_DB_PASSWORD_1
does not match, which is the case the anchor was added for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The category pill on the app detail header was rendered inert — no click
handler at all — while the identical pill on the app cards navigated to that
category's filter view. Both now come from one AppsManager.renderCategoryTag(),
so the detail pill behaves like the card pill and the two can't drift again.
The pill's glyph was an <img> of a category SVG, and those SVGs hardcode
#1e90ff. That only ever matched the dark-blue theme; on nebula (the default,
accent #00d4ff) and any other theme the icon read as a dark smudge next to its
own label. It's now painted as a CSS mask filled with currentColor, so it
always matches the pill's text on every theme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Up to date" answers one question — has the tag I track moved? — and an
abandoned project answers it reassuringly forever. The tag stays put, the
digest never changes, and the app reports as current while receiving no
security patches at all. Nothing in the UI could tell a healthy stable
app from a dead one.
An audit of all 34 anchor images found five in exactly that state:
speedtest (4.4y since rebuild), focalboard (2.8y — Mattermost dropped
support in 2023), pihole-unbound (2.3y), trilium (2.2y), unbound (1.8y).
The scan now records image_updated_at per app (one cheap Hub call inside
the existing registry window, cached between windows like everything
else) and emits stale_after_days from CFG_UPDATER_STALE_DAYS (365, 0
disables) so the UI and the config agree on one number.
Surfaced as an "unmaintained?" severity chip on the fleet row and a
dated explanation in the app detail. Phrased as an observation rather
than an accusation — plenty of small tools are simply finished — but it
does spell out the security consequence, because that is the part a user
cannot infer from "up to date".
Deliberately NOT a "needs action" row on the Overview board: it is not
fixable by pressing anything, and a permanently amber board teaches
people to ignore the board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.
The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.
Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The digest compare only ever asks about the tag already pinned, so it
answers "has my tag been rebuilt?" and can never answer "does a newer
version exist?". An app on v0.16 reports up to date forever while 0.17
ships. That is the gap between an app that updates and an app that is
current, and it silently affects every pinned app.
Adds tag enumeration for VERSIONED tags only (rolling tags already move
on their own): list the repo's tags, keep those sharing the current tag's
SHAPE, and pick the numerically greatest.
Shape matching is the whole safety story — v0.16 -> v#.# so it can never
"upgrade" you onto v0.16-alpine, 31-fpm-alpine onto 31-apache, or a date
tag onto a semver one. Comparison is component-wise numeric, so 0.10 > 0.9
and 1.0 > 0.99 (a string sort gets both wrong), with 10# forcing base ten
so an upstream "08" cannot be read as octal. 15 unit tests cover it.
Docker Hub only, deliberately: all three pinned apps live there, it needs
no auth, and the generic OCI tags/list wants a per-registry token dance.
Other registries stay quiet rather than guess. Throttled inside the
existing registry window and cached between windows so it cannot flicker.
Surfaced as INFORMATION, never an action: no button applies it, because a
version move can carry a data migration. `update_available` and the "up
to date" badge keep their exact meaning; the new state sits beside them
and points at the Version field.
Against the live registry: stalwart v0.16 is current, nextcloud is on
31-fpm-alpine with 34-fpm-alpine out, mastodon on v4.2.0 with v4.6.5 out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every themed dropdown on the app config page was dead: it rendered, but
clicking did nothing. Only that page — every other dropdown in the WebUI
worked.
renderAppDetail captured the config section's own innerHTML right after
displayConfigForm() had rendered it...
const configHTML = document.getElementById('config-section')?.innerHTML;
...67 lines later...
configSection.innerHTML = configHTML;
...and wrote the same string straight back. That is a no-op for the
markup and a catastrophe for behaviour: re-assigning innerHTML re-parses
the subtree, so every listener in it is destroyed.
custom-select.js had already wrapped each <select>, so the captured
string contained the .custom-select wrapper and the custom-select-native
class. The re-inserted copy therefore looked enhanced — which made the
enhancer correctly skip it as already-done — while having no click
handler at all. A dropdown that renders perfectly and does nothing.
The container is never wholesale-rewritten in that function (it updates
header/config/console individually), so the capture-and-restore had no
purpose. Both lines removed, with a comment stating that anything added
there must mutate the section rather than re-assign its innerHTML.
Diagnosed in a real browser rather than by reading: instrumenting
CustomSelect.build() showed the enhancer DID build a widget for the field
while the wrapper in the DOM was not the one it built, and patching the
innerHTML setter named apps-manager.js:785 as the writer. Verified live:
the dropdown opens, and picking an option sets the value (auto) and the
label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getFieldMappings/getConfigCategories fetched host-GENERATED files with
default caching, so a browser that had the page open before a release
kept rendering the previous release's config UI — a newly shipped field
(UPDATE_TYPE) simply never appeared, with nothing on screen to hint the
page was stale. Only a hard refresh fixed it.
Adds {cache:'no-store'} to both, plus the one configs.json read in this
file that was missing it while two others already had it. Cost is a
conditional request per config-page open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes that make the auto-updater a trustworthy background system:
* CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after the
05:00 backup cron; HH:MM-HH:MM wraps midnight, 'always' = any time).
Gates only the enqueue — scans keep running all day, so the Updates
page stays current and pending updates visibly wait for the window.
Malformed values fail closed and are rejected by the WebUI validator.
* "Check now" actually checks: an explicit `updater check` sets
UPDATER_REGISTRY_FORCE=1. The flag existed but nothing ever set it,
so the button silently reused the 6h digest cache and could not find
a build the user knew had shipped. Force also overrides interval 0,
which now means "manual-only" as documented in the roadmap.
* Registry stamp moved from /tmp to <system>/logs: the task processor
runs under PrivateTmp, so daemon and CLI each kept a separate 6h
clock and the daemon's reset on every service restart.
* A failed automatic attempt is no longer invisible: the scan emits
auto_attempted_digest (the one-shot no-retry stamp), and when it
matches the available build the UI stops promising an install that
will never come — per-app detail explains, the fleet row gets an
"auto failed" chip, and the Overview board counts it as needing you.
Also corrects the CFG_TIMEZONE label: it sets the containers' TZ only;
scheduled tasks follow the host clock (timedatectl), and the old
"Timezone for scheduled tasks" wording promised a knob that never
existed. The window + auto_window display state plainly WHEN updates
land, answering "how does the user know when the next update happens".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the decision half of the app updater. Detection (P2) and the
snapshot-first apply/revert (P3) were already real, but nothing ever
pressed the button — every update waited for a click.
CFG_<APP>_UPDATE_TYPE=auto|manual per app, default auto (33 templates)
CFG_UPDATER_AUTO=true|false master switch, default true
updaterAppPolicy resolves the two the way backupResolveStrategy already
resolves backup strategy: the global switch can only make things more
manual. updaterApplyAuto runs at the end of `updater check` and enqueues
the ordinary updater_apply task for each auto app that has an update —
never applies inline, so an automatic update is the same code path, task
log, History entry and Roll back button as a manual one.
Safety: each attempt stamps its target digest under generated/auto/, so a
build that fails is rolled back and then left alone rather than retried on
every scan; in-flight updater tasks are skipped so scans can't stack.
Tracked end to end: updates.json carries each app's resolved update_type,
History entries carry trigger=manual|auto. The WebUI says whether updates
install themselves, chips only the apps that opted out, labels automatic
history, and — since an auto app's pending update needs no decision — keeps
it off the Overview board's "Needs action" view.
Also fixes artifactApplyAuto enqueueing without --detach: it runs inside
the single-threaded task processor's own poll, so following the new task in
the foreground waits for a task that cannot start until it returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board landed on "Everything", so the rows that want a decision were
mixed in with the healthy one-liners on arrival. Default to the "Needs
action" chip instead, falling back to "Everything" when nothing is
pending (that view would otherwise be empty). An explicit chip click
still sticks for the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consistency pass across the App Center Overview area: no tab has a top-level
manual "Check" button anymore — scans are automatic.
- Overview tab: remove the header "Check now"; the hero already reports last-scan
time, and the unscanned sub-copy no longer points at a button that's gone.
- Improvements tab: remove the header "Check"; lead its body with the same
auto-check line (right-side Check-now nudge) as the Updates tab.
- Remove the now-unused checkBtn helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the per-app Updates treatment to the fleet App Center → Overview →
Updates list, and folds the manual check into the status line so it's the one
canonical (secondary) affordance rather than a top-level tab button:
- updater-page.js: renderAutoCheckLine() now ends with a right-aligned "↻ Check
now" button (data-updater-action="check" — both surfaces already wire it).
- overview-manager.js: drop the "Check"/"Check now" button from the Updates tab
header (keep "Update all", only when updates exist); lead renderUpdates() with
the auto-check line.
- overview.css: .updater-autocheck wraps on narrow widths; .updater-autocheck-btn
sits right (margin-left:auto), smaller.
- app-tabbed-manager.js: friendlier no-data copy ("You're all caught up — no
updates found for this app.").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-app Updates tab pushed a manual "Check" button (header + empty state)
even though scans run automatically on CFG_UPDATER_SCAN_INTERVAL — so an app with
nothing to update read like an empty/actionable page. Replace the manual Check
with a calm status line inside the panel: "Checked automatically · last checked X
· next check ~Y", backup-schedule style. The genuine Apply/Roll back actions stay
(applying is still manual and safe). No auto-apply.
- webui_updater_scan.sh: stamp scan_interval_minutes alongside generated_at in
updates.json so the display needs no separate config fetch (0 = auto off).
- updater-page.js: renderAutoCheckLine() + fmtRelFuture().
- app-tabbed-manager.js: drop the header/empty-state Check buttons; render the
auto-check line; friendlier no-data copy.
- overview.css: style .updater-autocheck (green dot live / muted when off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The expanded-row deep-link pushed /overview/<tab>?app=<app> — wrong prefix
and a ?query the SPA's path-based router drops on a cold load, so the row
never reopened from a shared URL. Switch to /apps/overview/updates/<app>,
matching the Migrate/Backups sub-tab path pattern, and parse the app from
that path segment in _honorAppDeepLink so the row expands on cold load too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce .ov-row-head vertical padding (12px -> 5px) and shrink the app icon
tile (32px -> 24px) so each Updates row is roughly half as tall.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the Fleet Overview Updates list rows to match the Tasks page:
each app now shows its icon tile, a status pill ('✓ Up to date' /
'↑ Update available' / '• Unscanned'), and a dedicated 'Details' toggle
button (chevron) in the row actions instead of the bare leading chevron.
Resolve the app icon from the slug (/core/icons/apps/<slug>.svg, hidden on
error) and prefer window.getAppDisplayName for a prettier title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-app Updates tab's recessed panel used padding: 4px 16px, so the first
section (VERSION) sat 4px from the top edge — looking unpadded — while the last
section ended 20px from the bottom. Match the sibling .backup-snapshots-container
idiom (uniform 16px) and zero the first/last sections' outer padding so the
container's inset sets an even top/bottom margin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior .ov-row override tied .updater-row on specificity, and updater.css
loads later, so `display:grid` won and the detail stayed boxed. Use the
two-class selector .updater-row.ov-row to win regardless of load order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fleet Updates expander row inherited .updater-row's 3-column grid, so an
open row's detail body (CVEs, recovery, history) landed in a narrow right-hand
`auto` column with dead space to its left. Make .ov-row a block: the head stacks
on top and the detail body uses the full width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trivy runs as a server whose vulnerability DB downloads on first boot; until it
lands no scan can produce results. Previously the updater generator wrote an
empty-but-valid cves.json the moment the file was missing, so installing Trivy
painted a green "no known vulnerabilities" all-clear that was actually a lie —
the DB hadn't even downloaded, and the Updates/Security view gave no signal.
Add an honest scanner state the WebUI branches on:
- containers/trivy/scripts/trivy_scan.sh — trivyScannerState (absent |
db_updating | ready) via `trivy version -f json`, trivyDbUpdatedAt, and
trivyScanImageCves (per-image scan normalized to {id,severity,package,
installed,fixed_in,url}, deduped). All degrade safely on error.
- webui_updater_scan.sh — stamp cves.json with scanner.state; only run real
per-image scans once the DB is ready. Always rewritten so state tracks live.
- updater-page.js — Security tab shows a loading box while the DB updates, an
install nudge when absent, and the genuine 🎉 only when ready+empty; Overview
CVE card sub + hint reflect the state.
- overview-manager.js — fleet Security row surfaces the "building CVE database"
pending state instead of silently omitting.
- function_manifest.sh — regenerated for the new trivy_scan.sh functions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With no apps tracked the sub line read '0 apps tracked · 0/0 backed up ·
last scan …' — pure noise. Show only the scan time in that case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>