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>
This commit is contained in:
parent
70ca195f30
commit
cea653f67b
@ -1211,3 +1211,40 @@ body.setup-wizard-open {
|
||||
.setup-dev-strip .setup-dev-strip-icon,
|
||||
.setup-dev-strip .setup-dev-strip-text { animation-duration: .01ms; }
|
||||
}
|
||||
|
||||
/* ---- Storage step -------------------------------------------------------
|
||||
Candidate drives reuse the .setup-app card so the step matches Recommended
|
||||
visually. The only additions are the verdict badge and the greyed state for
|
||||
a drive that cannot hold app data — shown WITH its reason rather than hidden,
|
||||
because "why isn't my drive listed?" is a support burden we don't need. */
|
||||
.setup-storage-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.setup-storage-badge-warn {
|
||||
background: rgba(224, 168, 0, 0.16);
|
||||
color: #b98900;
|
||||
border: 1px solid rgba(224, 168, 0, 0.35);
|
||||
}
|
||||
.setup-storage-badge-bad {
|
||||
background: rgba(200, 60, 60, 0.14);
|
||||
color: #c04040;
|
||||
border: 1px solid rgba(200, 60, 60, 0.32);
|
||||
}
|
||||
.setup-storage-card em {
|
||||
opacity: 0.85;
|
||||
font-style: normal;
|
||||
}
|
||||
.setup-storage-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.setup-storage-disabled input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@ -18,17 +18,42 @@ class SetupWizard {
|
||||
// visible step count is dynamic (4 for beginner, 5 for advanced).
|
||||
// installLevel defaults to 'beginner' so a user who races through
|
||||
// step 0 without touching anything gets the safe-default UX.
|
||||
this.stepNames = ['Experience', 'Identity', 'Domains', 'Recommended', 'Metrics'];
|
||||
this.stepIcons = ['🌱', '🪐', '🛰️', '🛡️', '📊'];
|
||||
// Storage sits BEFORE Recommended on purpose: a location has to exist
|
||||
// before an app can be placed on it, and the Recommended step can then
|
||||
// offer the big apps a home other than the system disk.
|
||||
this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Recommended', 'Metrics'];
|
||||
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛡️', '📊'];
|
||||
// Storage is skipped entirely when this box has nowhere else to put things
|
||||
// — one disk means one answer, and a step with nothing in it is noise.
|
||||
// Set by loadStorage() once the candidate scan comes back.
|
||||
this.hasStorageCandidates = false;
|
||||
this.storageCandidates = [];
|
||||
this.selectedStorage = [];
|
||||
this.installLevel = 'beginner';
|
||||
this.totalSteps = this._effectiveTotalSteps();
|
||||
this.domainCount = 0; // tracked dynamically as the user adds rows
|
||||
this.devMode = false; // unlocked by the Advanced-card 10-tap easter egg
|
||||
}
|
||||
|
||||
// Two conditional steps now, so the count is derived from a visibility test
|
||||
// rather than "length minus one". Metrics is advanced-only; Storage appears
|
||||
// only when a usable second filesystem was actually found.
|
||||
_stepVisible(idx) {
|
||||
const name = this.stepNames[idx];
|
||||
if (name === 'Metrics') return this.installLevel === 'advanced';
|
||||
if (name === 'Storage') return this.hasStorageCandidates;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Indices of the steps actually shown, in order. Everything else (progress,
|
||||
// next/prev, validation, submit) works off this rather than raw indices, so
|
||||
// hiding a step never leaves a gap in the numbering.
|
||||
_visibleSteps() {
|
||||
return this.stepNames.map((_, i) => i).filter((i) => this._stepVisible(i));
|
||||
}
|
||||
|
||||
_effectiveTotalSteps() {
|
||||
// Metrics (last step) is advanced-only; beginner skips it entirely.
|
||||
return this.installLevel === 'advanced' ? this.stepNames.length : this.stepNames.length - 1;
|
||||
return this._visibleSteps().length;
|
||||
}
|
||||
|
||||
initialize(setupDetector, onComplete = null) {
|
||||
@ -38,6 +63,9 @@ class SetupWizard {
|
||||
this.suggestName();
|
||||
this.renderAppTiles();
|
||||
this.preselectTimezone();
|
||||
// Async: the wizard is usable immediately and the Storage step appears if
|
||||
// and when the scan says there is something to choose.
|
||||
this.loadStorage();
|
||||
this.showStep(0);
|
||||
}
|
||||
|
||||
@ -194,8 +222,23 @@ class SetupWizard {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 4: Recommended apps (Traefik + Fail2ban) -->
|
||||
<!-- Step 4: Storage locations. Only rendered when the candidate
|
||||
scan found a filesystem LibrePortal isn't already using —
|
||||
otherwise there is exactly one answer and the step is skipped. -->
|
||||
<section class="setup-step" data-step="3">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Storage</div>
|
||||
<p class="setup-section-hint">
|
||||
Apps normally live on the system disk. If you have another drive, you can
|
||||
register it here and choose per app where its data goes.
|
||||
</p>
|
||||
<div id="sw-storage-list"></div>
|
||||
<p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 5: Recommended apps (Traefik + Fail2ban) -->
|
||||
<section class="setup-step" data-step="4">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Recommended Apps</div>
|
||||
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
||||
@ -219,7 +262,7 @@ class SetupWizard {
|
||||
default — they're only useful if the user wants the MONITORING
|
||||
toggle on apps to do anything. Advanced-only: this whole step
|
||||
is skipped when the user chose Beginner on step 1. -->
|
||||
<section class="setup-step" data-step="4">
|
||||
<section class="setup-step" data-step="5">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Metrics Apps</div>
|
||||
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
||||
@ -332,16 +375,104 @@ class SetupWizard {
|
||||
});
|
||||
}
|
||||
|
||||
showStep(n) {
|
||||
this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n));
|
||||
// The actual step index (position in stepNames) for the current visible slot.
|
||||
// currentStep is a VISIBLE position, not a raw index — with a hidden step in
|
||||
// the middle the two diverge, and conflating them was what made the old
|
||||
// index-based paging impossible to extend.
|
||||
_actualStep() {
|
||||
const vis = this._visibleSteps();
|
||||
return vis[Math.max(0, Math.min(vis.length - 1, this.currentStep))];
|
||||
}
|
||||
|
||||
this.container.querySelectorAll('.setup-step').forEach((el, idx) => {
|
||||
el.classList.toggle('active', idx === this.currentStep);
|
||||
// Fetch the candidate scan and decide whether the Storage step exists at all.
|
||||
// Deliberately fail-open-to-hidden: if the file is missing (a fresh install
|
||||
// where the generator has not run yet) the step simply does not appear, which
|
||||
// is the correct answer for the overwhelmingly common single-disk box.
|
||||
async loadStorage() {
|
||||
try {
|
||||
const res = await fetch('/data/system/storage.json', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.storageCandidates = Array.isArray(data.candidates) ? data.candidates : [];
|
||||
} catch (e) {
|
||||
console.log('[setup] storage scan unavailable, skipping the Storage step:', e.message);
|
||||
this.storageCandidates = [];
|
||||
}
|
||||
// A candidate that cannot work is still WORTH SHOWING (greyed, with the
|
||||
// reason) — "why isn't my drive listed?" is a support burden. But if every
|
||||
// candidate is unusable there is nothing to choose, so skip the step.
|
||||
this.hasStorageCandidates = this.storageCandidates.some(c => c.verdict !== 'refuse');
|
||||
this.totalSteps = this._effectiveTotalSteps();
|
||||
this.renderStorage();
|
||||
this.showStep(this.currentStep);
|
||||
}
|
||||
|
||||
renderStorage() {
|
||||
const list = this.container.querySelector('#sw-storage-list');
|
||||
const note = this.container.querySelector('#sw-storage-note');
|
||||
if (!list) return;
|
||||
|
||||
if (!this.storageCandidates.length) {
|
||||
list.innerHTML = '';
|
||||
if (note) note.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.storageCandidates.map((c, i) => {
|
||||
const refused = c.verdict === 'refuse';
|
||||
const warned = c.verdict === 'warn';
|
||||
const id = `sw-storage-${i}`;
|
||||
const detail = refused ? c.refusals : (warned ? c.warnings : '');
|
||||
const badge = refused
|
||||
? '<span class="setup-storage-badge setup-storage-badge-bad">unusable</span>'
|
||||
: (warned ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '');
|
||||
return `
|
||||
<label class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}">
|
||||
<input type="checkbox" id="${id}" data-storage-path="${this.escapeHtml(c.path)}" ${refused ? 'disabled' : ''}>
|
||||
<span class="setup-app-body">
|
||||
<span class="setup-app-name">
|
||||
${this.escapeHtml(c.path)} ${badge}
|
||||
</span>
|
||||
<span class="setup-app-desc">
|
||||
${this.escapeHtml(c.fstype)} · ${this.escapeHtml(c.size)} (${this.escapeHtml(c.free)} free)${c.removable ? ' · removable' : ''}
|
||||
${detail ? `<br><em>${this.escapeHtml(detail)}</em>` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
if (note) {
|
||||
note.innerHTML = 'A drive you tick is registered as a storage location during install. '
|
||||
+ 'You can add or remove locations later from the CLI (<code>libreportal storage</code>). '
|
||||
+ 'Unusable drives are shown greyed with the reason rather than hidden.';
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
|
||||
));
|
||||
}
|
||||
|
||||
collectStorage() {
|
||||
return Array.from(this.container.querySelectorAll('input[data-storage-path]:checked'))
|
||||
.map(cb => cb.dataset.storagePath);
|
||||
}
|
||||
|
||||
showStep(n) {
|
||||
this.totalSteps = this._effectiveTotalSteps();
|
||||
this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n));
|
||||
const actual = this._actualStep();
|
||||
|
||||
// Match on data-step, not on DOM position: a hidden step is still in the
|
||||
// DOM, so position and step index are not the same thing.
|
||||
this.container.querySelectorAll('.setup-step').forEach((el) => {
|
||||
el.classList.toggle('active', Number(el.dataset.step) === actual);
|
||||
});
|
||||
|
||||
const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100);
|
||||
const name = this.stepNames[this.currentStep];
|
||||
const icon = this.stepIcons[this.currentStep];
|
||||
const name = this.stepNames[actual];
|
||||
const icon = this.stepIcons[actual];
|
||||
this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`;
|
||||
this.container.querySelector('#sw-progress-step').innerHTML =
|
||||
`Step ${this.currentStep + 1} of ${this.totalSteps} <span class="setup-progress-sep">—</span> <span class="setup-progress-icon">${icon}</span> <span class="setup-progress-name">${name}</span>`;
|
||||
@ -376,7 +507,8 @@ class SetupWizard {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (idx === 3) {
|
||||
// 4 = Recommended (Storage was inserted at 3, shifting this along).
|
||||
if (idx === 4) {
|
||||
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||
if (traefikBox && traefikBox.checked) {
|
||||
const tEmail = $('#sw-traefik-email').value.trim();
|
||||
@ -389,7 +521,7 @@ class SetupWizard {
|
||||
}
|
||||
|
||||
next() {
|
||||
const err = this.validateStep(this.currentStep);
|
||||
const err = this.validateStep(this._actualStep());
|
||||
if (err) { this.showError(err); return; }
|
||||
this.clearError();
|
||||
this.showStep(this.currentStep + 1);
|
||||
@ -690,11 +822,13 @@ class SetupWizard {
|
||||
const $ = (id) => this.container.querySelector(id);
|
||||
this.clearError();
|
||||
|
||||
for (let i = 0; i < this.totalSteps; i++) {
|
||||
const visible = this._visibleSteps();
|
||||
for (let pos = 0; pos < visible.length; pos++) {
|
||||
const i = visible[pos];
|
||||
const err = this.validateStep(i);
|
||||
if (err) {
|
||||
console.log('[setup] step', i, 'validation failed:', err);
|
||||
this.showStep(i);
|
||||
this.showStep(pos);
|
||||
this.showError(err);
|
||||
this._submitting = false;
|
||||
return;
|
||||
@ -722,7 +856,11 @@ class SetupWizard {
|
||||
domains,
|
||||
apps,
|
||||
appOptions,
|
||||
traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : ''
|
||||
traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : '',
|
||||
// Paths ticked on the Storage step. Registering one is a privileged
|
||||
// action, so this is only a request — the host applier runs it through
|
||||
// the CLI and the root helper, which validate independently.
|
||||
storage: this.collectStorage()
|
||||
};
|
||||
|
||||
// Apply the experience choice to the WebUI immediately so the next
|
||||
|
||||
@ -24,6 +24,7 @@ setupApplyConfig()
|
||||
local dev_mode=$(echo "$payload" | jq -r '.dev_mode // empty')
|
||||
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
|
||||
local domains_json=$(echo "$payload" | jq -c '.domains // []')
|
||||
local storage_json=$(echo "$payload" | jq -c '.storage // []')
|
||||
|
||||
if [[ -n "$install_name" ]]; then
|
||||
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
|
||||
@ -51,6 +52,30 @@ setupApplyConfig()
|
||||
isSuccessful "Developer mode enabled"
|
||||
fi
|
||||
|
||||
# Storage locations ticked on the wizard's Storage step. This is a REQUEST,
|
||||
# not an instruction: storageAdd re-runs the fitness checks and the root
|
||||
# helper re-runs admission, so a path that should not be accepted is not,
|
||||
# no matter what arrived in the payload.
|
||||
local storage_count=$(echo "$storage_json" | jq -r 'length')
|
||||
if [[ "$storage_count" -gt 0 ]]; then
|
||||
local s i=0
|
||||
while [[ $i -lt $storage_count ]]; do
|
||||
s=$(echo "$storage_json" | jq -r ".[$i]")
|
||||
if [[ -n "$s" && "$s" != "null" ]]; then
|
||||
# Name it after the mount point's basename — short, recognisable,
|
||||
# and the user can rename it later from the location config.
|
||||
local sname="${s##*/}"
|
||||
[[ -z "$sname" ]] && sname="disk$((i+1))"
|
||||
if storageAdd "$s" "$sname" >/dev/null; then
|
||||
isSuccessful "Storage location '$sname' registered at $s"
|
||||
else
|
||||
isNotice "Could not register '$s' as a storage location — continuing without it."
|
||||
fi
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
fi
|
||||
|
||||
local domains_count=$(echo "$domains_json" | jq -r 'length')
|
||||
if [[ "$domains_count" -gt 0 ]]; then
|
||||
local i=0
|
||||
|
||||
@ -29,6 +29,7 @@ webui_scripts=(
|
||||
"webui/data/generators/config/webui_update_config.sh"
|
||||
"webui/data/generators/peers/webui_peers.sh"
|
||||
"webui/data/generators/system/webui_ssh_access.sh"
|
||||
"webui/data/generators/system/webui_storage_candidates.sh"
|
||||
"webui/data/generators/system/webui_system_disk.sh"
|
||||
"webui/data/generators/system/webui_system_health.sh"
|
||||
"webui/data/generators/system/webui_system_info.sh"
|
||||
|
||||
@ -1191,6 +1191,7 @@ declare -gA LP_FN_MAP=(
|
||||
[webuiGenerateLibrePortalConfig]="webui/data/generators/apps/webui_config.sh"
|
||||
[webuiGeneratePeers]="webui/data/generators/peers/webui_peers.sh"
|
||||
[webuiGenerateSshAccess]="webui/data/generators/system/webui_ssh_access.sh"
|
||||
[webuiGenerateStorageCandidates]="webui/data/generators/system/webui_storage_candidates.sh"
|
||||
[webuiGenerateSystemConfigs]="webui/data/generators/config/webui_generate_configs.sh"
|
||||
[webuiLibrePortalUpdate]="webui/webui_updater.sh"
|
||||
[webuiPatchAppConfigJson]="webui/data/generators/apps/webui_config_patch.sh"
|
||||
@ -2413,6 +2414,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[webuiGenerateLibrePortalConfig]="scripts"
|
||||
[webuiGeneratePeers]="scripts"
|
||||
[webuiGenerateSshAccess]="scripts"
|
||||
[webuiGenerateStorageCandidates]="scripts"
|
||||
[webuiGenerateSystemConfigs]="scripts"
|
||||
[webuiLibrePortalUpdate]="scripts"
|
||||
[webuiPatchAppConfigJson]="scripts"
|
||||
@ -3672,6 +3674,7 @@ webuiGenerateBackupSnapshots() { unset -f webuiGenerateBackupSnapshots; __lpAuto
|
||||
webuiGenerateLibrePortalConfig() { unset -f webuiGenerateLibrePortalConfig; __lpAutoload "${install_scripts_dir}webui/data/generators/apps/webui_config.sh"; webuiGenerateLibrePortalConfig "$@"; }
|
||||
webuiGeneratePeers() { unset -f webuiGeneratePeers; __lpAutoload "${install_scripts_dir}webui/data/generators/peers/webui_peers.sh"; webuiGeneratePeers "$@"; }
|
||||
webuiGenerateSshAccess() { unset -f webuiGenerateSshAccess; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_ssh_access.sh"; webuiGenerateSshAccess "$@"; }
|
||||
webuiGenerateStorageCandidates() { unset -f webuiGenerateStorageCandidates; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_storage_candidates.sh"; webuiGenerateStorageCandidates "$@"; }
|
||||
webuiGenerateSystemConfigs() { unset -f webuiGenerateSystemConfigs; __lpAutoload "${install_scripts_dir}webui/data/generators/config/webui_generate_configs.sh"; webuiGenerateSystemConfigs "$@"; }
|
||||
webuiLibrePortalUpdate() { unset -f webuiLibrePortalUpdate; __lpAutoload "${install_scripts_dir}webui/webui_updater.sh"; webuiLibrePortalUpdate "$@"; }
|
||||
webuiPatchAppConfigJson() { unset -f webuiPatchAppConfigJson; __lpAutoload "${install_scripts_dir}webui/data/generators/apps/webui_config_patch.sh"; webuiPatchAppConfigJson "$@"; }
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Storage data for the WebUI: registered locations + unregistered candidates.
|
||||
#
|
||||
# Written as a plain JSON file under frontend/data like every other generator,
|
||||
# so the setup wizard and the Disks view read it with a fetch and no new backend
|
||||
# route. Read-only by construction — registering a location is a mutating action
|
||||
# and goes through the task system → CLI → root helper, never through here.
|
||||
#
|
||||
# The wizard uses `candidates` to decide whether its Storage step appears at all:
|
||||
# an empty list means this box has nowhere else to put things, and the step is
|
||||
# skipped rather than shown with nothing in it.
|
||||
|
||||
webuiGenerateStorageCandidates()
|
||||
{
|
||||
local out_dir="$(webuiDir)/frontend/data/system"
|
||||
local out_file="$out_dir/storage.json"
|
||||
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
||||
|
||||
local tmp; tmp=$(mktemp) || return 1
|
||||
|
||||
# --- registered locations (including any whose drive is absent) ----------
|
||||
local locations="[]" first=1
|
||||
locations="["
|
||||
local id state path name_var name apps
|
||||
while IFS=$'\t' read -r id state path; do
|
||||
[[ -z "$id" ]] && continue
|
||||
name_var="CFG_STORAGE_LOC_${id}_NAME"
|
||||
name="${!name_var:-location-$id}"
|
||||
apps="$(storageAppsOnRoot "$path" 2>/dev/null | paste -sd, -)"
|
||||
(( first )) || locations+=","
|
||||
first=0
|
||||
locations+="{\"id\":\"$(_lpJsonEsc "$id")\",\"name\":\"$(_lpJsonEsc "$name")\",\"path\":\"$(_lpJsonEsc "$path")\",\"state\":\"$(_lpJsonEsc "$state")\",\"apps\":\"$(_lpJsonEsc "$apps")\"}"
|
||||
done < <(runStorage verify 2>/dev/null)
|
||||
locations+="]"
|
||||
|
||||
# --- unregistered candidates, with a fitness verdict each ---------------
|
||||
local candidates="[" cfirst=1
|
||||
local target source fstype size avail uuid rm_flag role
|
||||
while IFS=$'\t' read -r target source fstype size avail uuid rm_flag role; do
|
||||
[[ -z "$target" ]] && continue
|
||||
# Only offer filesystems LibrePortal isn't already using.
|
||||
[[ "$role" == "free" ]] || continue
|
||||
|
||||
local sev check msg refusals="" warnings=""
|
||||
while IFS=$'\t' read -r sev check msg; do
|
||||
case "$sev" in
|
||||
refuse) refusals+="${refusals:+; }$msg" ;;
|
||||
warn) warnings+="${warnings:+; }$msg" ;;
|
||||
esac
|
||||
done < <(storageCheckPath "$target" 2>/dev/null)
|
||||
|
||||
local verdict="ok"
|
||||
[[ -n "$warnings" ]] && verdict="warn"
|
||||
[[ -n "$refusals" ]] && verdict="refuse"
|
||||
|
||||
(( cfirst )) || candidates+=","
|
||||
cfirst=0
|
||||
candidates+="{\"path\":\"$(_lpJsonEsc "$target")\""
|
||||
candidates+=",\"device\":\"$(_lpJsonEsc "$source")\""
|
||||
candidates+=",\"fstype\":\"$(_lpJsonEsc "$fstype")\""
|
||||
candidates+=",\"size\":\"$(_lpJsonEsc "$size")\""
|
||||
candidates+=",\"free\":\"$(_lpJsonEsc "$avail")\""
|
||||
candidates+=",\"removable\":$([[ "$rm_flag" == "1" ]] && echo true || echo false)"
|
||||
candidates+=",\"verdict\":\"$verdict\""
|
||||
candidates+=",\"refusals\":\"$(_lpJsonEsc "$refusals")\""
|
||||
candidates+=",\"warnings\":\"$(_lpJsonEsc "$warnings")\"}"
|
||||
done < <(storageScanCandidates 2>/dev/null)
|
||||
candidates+="]"
|
||||
|
||||
cat > "$tmp" <<EOF
|
||||
{
|
||||
"primary": "$(_lpJsonEsc "$(primaryRoot)")",
|
||||
"locations": $locations,
|
||||
"candidates": $candidates,
|
||||
"updated": "$(date -Iseconds)"
|
||||
}
|
||||
EOF
|
||||
|
||||
runFileWrite "$out_file" < "$tmp"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
}
|
||||
|
||||
_lpJsonEsc()
|
||||
{
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/ }"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user