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>
This commit is contained in:
parent
03d4788a2c
commit
fd0a0fd08c
@ -109,6 +109,10 @@ class SystemOrchestrator {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create and show setup wizard
|
||||
const setupWizard = new SetupWizard();
|
||||
// A handle on the running wizard, for the headless tests and for anyone
|
||||
// debugging a step from the console. The instance is otherwise local to
|
||||
// this promise and unreachable once show() returns.
|
||||
window.setupWizard = setupWizard;
|
||||
setupWizard.initialize(this.setupDetector, async () => {
|
||||
try {
|
||||
// Setup completed, continue with normal loading
|
||||
|
||||
@ -1421,6 +1421,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
|
||||
.setup-storage-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@ -1445,3 +1446,18 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
|
||||
margin-top: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* A custom storage path that cannot be used says so under the box, rather
|
||||
than being quietly swapped for the system disk on the apply side. Its own
|
||||
flex line, offset to sit under the input rather than under the label. */
|
||||
.setup-storage-choice-custom { padding-left: 132px; }
|
||||
.setup-storage-choice-err {
|
||||
flex: 0 0 100%;
|
||||
margin: -4px 0 0;
|
||||
font-size: 0.82em;
|
||||
color: #ff9b8f;
|
||||
}
|
||||
.setup-storage-choice-err:empty { display: none; }
|
||||
.setup-storage-choice input.is-invalid {
|
||||
border-color: rgba(255, 120, 100, 0.75);
|
||||
}
|
||||
|
||||
@ -5,6 +5,10 @@
|
||||
// /api/setup/save which fans out into separate tasks per app, then this UI
|
||||
// hands off to the tasks page focused on the first task.
|
||||
|
||||
// Marker for "I will type a path" in the two storage dropdowns. Not a valid
|
||||
// path itself, so it can never be mistaken for one.
|
||||
const CUSTOM_PATH = '__custom__';
|
||||
|
||||
class SetupWizard {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
@ -608,10 +612,17 @@ class SetupWizard {
|
||||
_storageCard(c, key, opts) {
|
||||
const o = opts || {};
|
||||
const refused = c.verdict === 'refuse';
|
||||
// A registered location whose drive is not currently mounted is a different
|
||||
// thing from one that has a problem: nothing is wrong with it, it is simply
|
||||
// not here. Calling that "needs care" next to a blank size and an empty
|
||||
// meter told a new user nothing at all — the row looked like a drive that
|
||||
// had failed to load rather than one that is unplugged.
|
||||
const offline = c.state === 'unmounted';
|
||||
const badge = o.system
|
||||
? '<span class="setup-storage-badge setup-storage-badge-ok">system</span>'
|
||||
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
|
||||
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : ''));
|
||||
: (offline ? '<span class="setup-storage-badge setup-storage-badge-warn">not connected</span>'
|
||||
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
|
||||
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '')));
|
||||
|
||||
// A registered location has a name the user chose; an unregistered
|
||||
// candidate only has a path.
|
||||
@ -623,8 +634,10 @@ class SetupWizard {
|
||||
<span class="setup-app-body">
|
||||
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
|
||||
<span class="setup-app-desc">
|
||||
${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''}
|
||||
${this._storageMeter(c)}
|
||||
${offline
|
||||
? `Not mounted right now · ${this.escapeHtml(c.path || '')}`
|
||||
: `${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''}
|
||||
${this._storageMeter(c)}`}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
|
||||
@ -635,8 +648,14 @@ class SetupWizard {
|
||||
_storageChoices() {
|
||||
const opts = [{ value: 'primary', label: this._primaryLabel() }];
|
||||
this.storageCandidates
|
||||
.filter(c => c.verdict !== 'refuse')
|
||||
.forEach(c => opts.push({ value: c.path, label: c.path }));
|
||||
// Not an unmounted one: choosing it would put app data on a bare
|
||||
// mountpoint, which is the failure this whole feature exists to avoid.
|
||||
.filter(c => c.verdict !== 'refuse' && c.state !== 'unmounted')
|
||||
.forEach(c => opts.push({ value: c.path, label: c.name ? `${c.name} (${c.path})` : c.path }));
|
||||
// Somewhere the scan did not find: a NAS mount, an LVM volume, a directory
|
||||
// on a drive already in use. The scan lists whole filesystems, so anything
|
||||
// that is a path rather than a disk had no way in before this.
|
||||
opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' });
|
||||
return opts;
|
||||
}
|
||||
|
||||
@ -687,14 +706,23 @@ class SetupWizard {
|
||||
return;
|
||||
}
|
||||
|
||||
// A value that is not one of the offered options is a path the user typed,
|
||||
// so the control has to come back up showing Custom path rather than
|
||||
// silently snapping to the first entry.
|
||||
const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v);
|
||||
const row = (id, label, tip, value) => `
|
||||
<div class="setup-storage-choice">
|
||||
<span class="setup-storage-choice-label">${label}
|
||||
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
|
||||
</span>
|
||||
<select id="${id}" class="form-control">
|
||||
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === value ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
|
||||
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${(o.value === value || (o.value === CUSTOM_PATH && isCustom(value))) ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="setup-storage-choice setup-storage-choice-custom" id="${id}-custom-row" style="${isCustom(value) ? '' : 'display:none;'}">
|
||||
<input type="text" id="${id}-custom" class="form-control"
|
||||
placeholder="/mnt/nas/libreportal" value="${isCustom(value) ? this.escapeHtml(value) : ''}">
|
||||
<span class="setup-storage-choice-err" id="${id}-custom-err"></span>
|
||||
</div>`;
|
||||
|
||||
box.innerHTML =
|
||||
@ -706,14 +734,61 @@ class SetupWizard {
|
||||
this.storageDefault) +
|
||||
'<div class="setup-storage-choice-msg" id="sw-storage-system-msg" style="display:none;"></div>';
|
||||
|
||||
box.querySelector('#sw-storage-system').addEventListener('change', (e) => {
|
||||
this.storageSystemChoice = e.target.value;
|
||||
this.renderStorageSystemMsg();
|
||||
});
|
||||
box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
|
||||
this.storageDefault = e.target.value;
|
||||
});
|
||||
// Choosing "Custom path" reveals the input and holds the value at empty
|
||||
// until something is typed — the sentinel must never reach the payload.
|
||||
const wire = (id, apply) => {
|
||||
const sel = box.querySelector(`#${id}`);
|
||||
const row = box.querySelector(`#${id}-custom-row`);
|
||||
const inp = box.querySelector(`#${id}-custom`);
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', (e) => {
|
||||
const custom = e.target.value === CUSTOM_PATH;
|
||||
if (row) row.style.display = custom ? '' : 'none';
|
||||
apply(custom ? (inp && inp.value.trim()) || '' : e.target.value);
|
||||
if (custom && inp) inp.focus();
|
||||
this._syncStorageNav();
|
||||
});
|
||||
if (inp) inp.addEventListener('input', () => {
|
||||
if (sel.value === CUSTOM_PATH) apply(inp.value.trim());
|
||||
this._syncStorageNav();
|
||||
});
|
||||
};
|
||||
wire('sw-storage-system', (v) => { this.storageSystemChoice = v; this.renderStorageSystemMsg(); });
|
||||
wire('sw-storage-apps', (v) => { this.storageDefault = v; });
|
||||
this.renderStorageSystemMsg();
|
||||
this._syncStorageNav();
|
||||
}
|
||||
|
||||
// A half-typed custom path must not be able to reach the payload. The apply
|
||||
// side would refuse it and fall back to the system disk, and a silent
|
||||
// fallback is indistinguishable from having chosen the system disk on
|
||||
// purpose — so block Continue and say why instead.
|
||||
_customPathProblem(v) {
|
||||
if (!v) return 'Enter a full path, starting with /';
|
||||
if (!v.startsWith('/')) return 'Use a full path, starting with /';
|
||||
if (/\s/.test(v)) return 'Paths with spaces are not supported';
|
||||
if (v === '/' || /^\/(boot|dev|etc|proc|run|sys|usr|bin|sbin|lib|lib64|var)(\/|$)/.test(v)) {
|
||||
return 'Pick a path outside the system directories';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Returns the first problem found, and paints it beside the offending input.
|
||||
_syncStorageNav() {
|
||||
if (!this.container) return '';
|
||||
let blocked = '';
|
||||
['sw-storage-system', 'sw-storage-apps'].forEach((id) => {
|
||||
const sel = this.container.querySelector('#' + id);
|
||||
const inp = this.container.querySelector('#' + id + '-custom');
|
||||
const err = this.container.querySelector('#' + id + '-custom-err');
|
||||
if (!sel || !inp) return;
|
||||
const problem = sel.value === CUSTOM_PATH
|
||||
? this._customPathProblem(inp.value.trim()) : '';
|
||||
if (err) err.textContent = problem;
|
||||
inp.classList.toggle('is-invalid', !!problem);
|
||||
if (problem) blocked = problem;
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
|
||||
@ -1327,6 +1402,12 @@ class SetupWizard {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3 = Storage. Only a typed custom path can be invalid; the scanned
|
||||
// options all came from the backend.
|
||||
if (idx === 3) {
|
||||
const problem = this._syncStorageNav();
|
||||
if (problem) return problem;
|
||||
}
|
||||
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
|
||||
if (idx === 6) {
|
||||
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||
|
||||
@ -540,6 +540,65 @@ default-layout host onto it means *every* app takes the stage-and-move branch,
|
||||
and staging (disk 1) and destination (disk 2) are different devices, so
|
||||
`app-adopt` exercises its copy path rather than `mv`.
|
||||
|
||||
## 12.6 — The Storage step, after watching someone read it
|
||||
|
||||
Two things about the wizard's Storage step were wrong in a way only a fresh
|
||||
pair of eyes catches.
|
||||
|
||||
**A registered drive that is unplugged.** `storage list` reports it as
|
||||
`unmounted`, and the step rendered that through the same path as every other
|
||||
candidate: a yellow "needs care" badge, "free of" with no numbers either side,
|
||||
and an empty meter. To someone installing for the first time that reads as *the
|
||||
scan found two broken disks* — there is nothing on the card connecting it to a
|
||||
drive they registered earlier and have since unplugged. It now says **not
|
||||
connected**, names the path, and draws no meter, because a meter with nothing in
|
||||
it is a claim about free space that was never measured.
|
||||
|
||||
The same locations are also withheld from the two dropdowns. The wizard cannot
|
||||
stat a directory on a drive that is absent, so it cannot promise an app placed
|
||||
there would have anywhere to write.
|
||||
|
||||
**Custom paths.** The dropdowns offered only what the scan turned up, which is
|
||||
wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do
|
||||
not rank as a candidate. Both now end in *Custom path…*, revealing a text box.
|
||||
|
||||
The validation lives in `validateStep(3)` rather than in 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*. This is the failure shape this project keeps
|
||||
having to fix (§10, and the compose-guard and `app-data-remove` bugs before it),
|
||||
so the step blocks and says which rule the path broke.
|
||||
|
||||
A path typed here is not a registered location, so `setup_apply.sh` registers it
|
||||
through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through
|
||||
`storageAdd` rather than writing the path straight into the config is what keeps
|
||||
the empty-directory admission rule and the fitness checks in play — the
|
||||
alternative silently adopts a directory full of someone else's data. It is named
|
||||
after its own basename, so it reads as `nas` in the placement menus rather than
|
||||
`location-3`.
|
||||
|
||||
`libreportal storage remove` also learned to accept the name the listing prints.
|
||||
It matched on id and path only, so `remove location-3` failed against a row the
|
||||
table displayed as `location-3`.
|
||||
|
||||
### What the test found about the tests
|
||||
|
||||
`lp-storage-custom-test` drives the step in a real browser. Two things it caught
|
||||
about itself are worth recording, because both are the same shape as the bugs it
|
||||
exists to prevent — a check whose failure mode is to not run.
|
||||
|
||||
It first counted "how many cards say *not connected*" and asserted things about
|
||||
those. Disabling the feature makes that count zero, `every()` over an empty list
|
||||
is true, and the whole block passed while asserting nothing. The expected count
|
||||
now comes from the feed (`storageCandidates` in state `unmounted`), so removing
|
||||
the rendering is a failure rather than an empty set.
|
||||
|
||||
And both browser tests skipped — exit 0 — whenever the page returned nothing.
|
||||
Run under `sudo`, where chromium refuses to start, they reported PASS having
|
||||
checked nothing at all. They now probe the WebUI with `lp-shot --url` and curl:
|
||||
if it answers HTTP then the browser is the only thing that can have broken, and
|
||||
that is a failure, not a skip.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. ~~**Naming.** "Storage location" vs "backup location" in the same UI~~ — **resolved (2026-08-24):** build the Disks view (§7.1). The device becomes the organising concept and the two registries become *roles* on it, so the user never has to hold the distinction to understand their own hardware. Registries stay separate underneath.
|
||||
|
||||
2
init.sh
2
init.sh
@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
|
||||
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
|
||||
# tells the updater the new release needs a root re-install (which re-bakes them).
|
||||
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
|
||||
footprint_version=10
|
||||
footprint_version=11
|
||||
footprint_marker="$lp_lib_dir/.footprint_version"
|
||||
|
||||
# Directories — three independently-relocatable roots (see scripts/source/paths.sh
|
||||
|
||||
@ -23,6 +23,16 @@ fail=0
|
||||
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
|
||||
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
|
||||
|
||||
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
|
||||
# then the browser is the only thing that can have failed, and that is a
|
||||
# failure — skipping there is how this test reported success under sudo, where
|
||||
# chromium refuses to start at all.
|
||||
lp_reachable() {
|
||||
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
|
||||
[[ -n "$u" ]] || return 1
|
||||
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
|
||||
}
|
||||
|
||||
read -r -d '' DRIVE <<'JS'
|
||||
const DUMMY = 'dummy-not-a-real-secret-0000';
|
||||
const out = {};
|
||||
@ -139,7 +149,10 @@ JS
|
||||
|
||||
OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
|
||||
if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
|
||||
echo " SKIP no WebUI reachable, or the step did not load"
|
||||
if lp_reachable; then
|
||||
echo " FAIL the WebUI is up but the step returned nothing (browser failed?)"; exit 1
|
||||
fi
|
||||
echo " SKIP no WebUI reachable"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then
|
||||
|
||||
@ -14,6 +14,7 @@ Environment:
|
||||
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
|
||||
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
|
||||
--eval ROUTE JS run JS in the page and print the result; no screenshot
|
||||
--url print the base URL that would be used, and exit
|
||||
LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
|
||||
tab, expand a row. Awaited, so async handlers finish.
|
||||
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
|
||||
@ -369,6 +370,14 @@ def main():
|
||||
#
|
||||
# lp-shot --token -> the raw cookie VALUE
|
||||
# lp-shot --cookie-js -> a document.cookie assignment to paste/eval
|
||||
# --url prints the base URL that would be used, and nothing else. Tests
|
||||
# probe it with curl to tell "the WebUI is down, skip" apart from "the
|
||||
# WebUI is up and the browser broke" — a browser test that skips on both
|
||||
# reports success while asserting nothing.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--url":
|
||||
print(base_url())
|
||||
sys.exit(0)
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("--token", "--cookie-js"):
|
||||
token, src = mint_token()
|
||||
if os.environ.get("LP_SHOT_VERBOSE"):
|
||||
|
||||
138
scripts/dev/lp-storage-custom-test
Executable file
138
scripts/dev/lp-storage-custom-test
Executable file
@ -0,0 +1,138 @@
|
||||
#!/bin/bash
|
||||
# Drive the wizard's Storage step in a real browser.
|
||||
#
|
||||
# scripts/dev/lp-storage-custom-test # needs a running WebUI
|
||||
#
|
||||
# Two things this guards.
|
||||
#
|
||||
# A location whose drive is absent used to render as a warn card — "needs care",
|
||||
# "free of" with no numbers, an empty meter. A new user has no way to read that
|
||||
# as "this is a drive you registered and it is unplugged", so it looked like the
|
||||
# scan had found two broken disks. It must now say it is not connected, name the
|
||||
# path, and draw no meter.
|
||||
#
|
||||
# And a typed custom path must never reach the payload half-typed. The apply
|
||||
# side refuses a relative or system path and falls back to the system disk —
|
||||
# a fallback that looks exactly like having chosen the system disk on purpose,
|
||||
# which is the failure shape this project keeps having to fix. validateStep
|
||||
# has to block it at the step instead.
|
||||
#
|
||||
# One page load: the whole interaction runs in a single `lp-shot --eval`.
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SHOT="$REPO/scripts/dev/lp-shot"
|
||||
fail=0
|
||||
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
|
||||
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
|
||||
|
||||
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
|
||||
# then the browser is the only thing that can have failed, and that is a
|
||||
# failure — skipping there is how this test reported success under sudo, where
|
||||
# chromium refuses to start at all.
|
||||
lp_reachable() {
|
||||
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
|
||||
[[ -n "$u" ]] || return 1
|
||||
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
|
||||
}
|
||||
|
||||
read -r -d '' DRIVE <<'JS'
|
||||
const out = {};
|
||||
const w = window.setupWizard;
|
||||
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
|
||||
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
|
||||
|
||||
const sel = document.querySelector('#sw-storage-apps');
|
||||
const inp = document.querySelector('#sw-storage-apps-custom');
|
||||
const row = document.querySelector('#sw-storage-apps-custom-row');
|
||||
const err = document.querySelector('#sw-storage-apps-custom-err');
|
||||
if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' });
|
||||
|
||||
// An unmounted location must not be offered as somewhere to put app data:
|
||||
// the wizard cannot check its free space or write to it.
|
||||
// How many SHOULD be offline comes from the feed, not from the rendering.
|
||||
// Counting the cards that say "not connected" would make every assertion
|
||||
// below vacuous the moment the feature stopped working.
|
||||
const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'));
|
||||
const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted');
|
||||
out.expectedOffline = expected.length;
|
||||
const offline = cards.filter(c =>
|
||||
expected.some(e => e.path && c.textContent.includes(e.path)));
|
||||
out.offlineCards = offline.length;
|
||||
out.offlineSayNotConnected = offline.length > 0
|
||||
&& offline.every(c => /not connected/i.test(c.textContent));
|
||||
out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent));
|
||||
out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent));
|
||||
out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter'));
|
||||
out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent));
|
||||
out.offlineOffered = Array.from(sel.options)
|
||||
.some(o => offline.some(c => o.value && c.textContent.includes(o.value)));
|
||||
|
||||
out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__');
|
||||
out.rowHiddenAtRest = row.style.display === 'none';
|
||||
|
||||
sel.value = '__custom__'; fire(sel, 'change');
|
||||
out.rowShownOnPick = row.style.display !== 'none';
|
||||
out.emptyBlocks = !!w.validateStep(3);
|
||||
|
||||
const probe = (v) => { inp.value = v; fire(inp, 'input');
|
||||
return { blocks: !!w.validateStep(3),
|
||||
marked: inp.classList.contains('is-invalid'),
|
||||
said: (err && err.textContent) || '' }; };
|
||||
out.relative = probe('mnt/nas');
|
||||
out.systemDir = probe('/etc/libreportal');
|
||||
out.root = probe('/');
|
||||
out.spaces = probe('/mnt/my disk');
|
||||
out.good = probe('/mnt/nas/apps');
|
||||
out.goodStored = w.storageDefault;
|
||||
|
||||
// Back to a scanned option: never blocked, and the error clears.
|
||||
sel.value = 'primary'; fire(sel, 'change');
|
||||
out.primaryBlocks = !!w.validateStep(3);
|
||||
out.primaryStored = w.storageDefault;
|
||||
return JSON.stringify(out);
|
||||
JS
|
||||
|
||||
J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g')
|
||||
if [[ -z "$J" ]]; then
|
||||
if lp_reachable; then
|
||||
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
|
||||
fi
|
||||
echo " SKIP no WebUI reachable"; exit 0
|
||||
fi
|
||||
g(){ echo "$J" | jq -r "$1" 2>/dev/null; }
|
||||
|
||||
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
|
||||
|
||||
echo "unmounted locations"
|
||||
if [[ "$(g .expectedOffline)" == "0" ]]; then
|
||||
echo " SKIP no unmounted location registered — nothing to check"
|
||||
else
|
||||
chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)"
|
||||
chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true
|
||||
chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true
|
||||
chk "name the path" "$(g .offlineNamesPath)" true
|
||||
chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true
|
||||
chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
|
||||
chk "are not offered as a target" "$(g .offlineOffered)" false
|
||||
fi
|
||||
|
||||
echo "custom path"
|
||||
chk "the option exists" "$(g .hasCustomOption)" true
|
||||
chk "input hidden until picked" "$(g .rowHiddenAtRest)" true
|
||||
chk "input shown once picked" "$(g .rowShownOnPick)" true
|
||||
chk "empty blocks the step" "$(g .emptyBlocks)" true
|
||||
chk "relative path blocks" "$(g .relative.blocks)" true
|
||||
chk "relative path is marked" "$(g .relative.marked)" true
|
||||
chk "relative path says why" "$(g '.relative.said != ""')" true
|
||||
chk "system dir blocks" "$(g .systemDir.blocks)" true
|
||||
chk "/ blocks" "$(g .root.blocks)" true
|
||||
chk "path with spaces blocks" "$(g .spaces.blocks)" true
|
||||
chk "an absolute path passes" "$(g .good.blocks)" false
|
||||
chk "and is not marked" "$(g .good.marked)" false
|
||||
chk "and its error is cleared" "$(g '.good.said == ""')" true
|
||||
chk "and it is what gets stored" "$(g .goodStored)" /mnt/nas/apps
|
||||
chk "a scanned option never blocks" "$(g .primaryBlocks)" false
|
||||
chk "and replaces the custom value" "$(g .primaryStored)" primary
|
||||
|
||||
[[ $fail -eq 0 ]] && echo "storage custom-path test: OK"
|
||||
exit $fail
|
||||
@ -102,6 +102,29 @@ setupApplyConfig()
|
||||
if declare -f storageLocationName >/dev/null 2>&1; then
|
||||
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
|
||||
fi
|
||||
# A path typed into the wizard's "Custom path" box is not a registered
|
||||
# location yet, so the lookup above finds nothing. Register it — through
|
||||
# storageAdd, because that is what enforces the empty-directory
|
||||
# admission rule and the fitness checks; writing the path straight into
|
||||
# the config would skip both and is how an app ends up on a directory
|
||||
# root never agreed to own.
|
||||
if [[ -z "$default_name" || "$default_name" == "default" ]] \
|
||||
&& [[ "$storage_default" == /* ]] \
|
||||
&& declare -f storageAdd >/dev/null 2>&1; then
|
||||
# Named after the directory, so it reads as "nas" in the app
|
||||
# placement menus rather than "location-3".
|
||||
local custom_name
|
||||
custom_name=$(basename -- "$storage_default" | tr -c 'a-zA-Z0-9-' '-' | sed 's/^-*//; s/-*$//')
|
||||
[[ -z "$custom_name" ]] && custom_name="custom"
|
||||
if storageAdd "$storage_default" "$custom_name" >/dev/null 2>&1; then
|
||||
storageCacheReset 2>/dev/null || true
|
||||
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
|
||||
[[ -n "$default_name" ]] && isSuccessful "Registered '$storage_default' as a storage location."
|
||||
else
|
||||
isNotice "Could not use '$storage_default' — it must be an empty directory outside the system paths."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$default_name" && "$default_name" != "default" ]]; then
|
||||
updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name"
|
||||
isSuccessful "New apps will store their data on '$default_name'"
|
||||
|
||||
@ -413,6 +413,16 @@ remove() {
|
||||
if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then
|
||||
found_path="${_path%/}"; found_id="$_id"; break
|
||||
fi
|
||||
# Also match the name the listing prints, since that is what a person
|
||||
# reading the table will type. Includes the "location-N" fallback the
|
||||
# marker records when a location was added without one.
|
||||
if [[ -f "${_path%/}/$MARKER" ]]; then
|
||||
local _name
|
||||
_name=$(sed -n 's/^name=//p' "${_path%/}/$MARKER" 2>/dev/null | head -1)
|
||||
if [[ -n "$_name" && "$_name" == "$want" ]]; then
|
||||
found_path="${_path%/}"; found_id="$_id"; break
|
||||
fi
|
||||
fi
|
||||
done < "$REGISTRY"
|
||||
[[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user