From ea882cafbf5c31a8fdcfbba606ad499e0f6213ed Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:01:15 -0400 Subject: [PATCH] Storage panel: say what is mounted at /data and warn about Docker's anonymous volume "mounted" was true even when /data was the hidden anonymous volume Docker creates for the Dockerfile's VOLUME, so the app looked fine while the user's host folder stayed empty and the data was lost when the container was recreated. Read the mount source and report host folder, named volume or anonymous volume (flagged), in Settings and the startup log. Co-Authored-By: Claude Sonnet 5.5 --- public/app.js | 8 ++++++-- server.js | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index 1a54b1a..3445d85 100644 --- a/public/app.js +++ b/public/app.js @@ -1088,9 +1088,13 @@ function viewSettings() { if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; } }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; }); jfetch('/api/system').then(i => { - const ok = i.mounted !== false && i.writable, row = (t, d, bad) => `
${t}${d}
`; + const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable; + const row = (t, d, bad) => `
${t}${d}
`; + const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : ''; $('#stor').innerHTML = - row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept', i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.' : !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + (i.mounted ? ' (mounted)' : ''), !ok) + row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept safely', anon ? 'This is a temporary Docker volume (' + esc(i.source.name) + '…), NOT your host folder. It is deleted when the container is recreated, and your folder stays empty. Check that your stack mounts the host folder at ' + esc(i.dir) + '.' + : i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.' + : !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + ' (mounted' + where + ')', !ok) + row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'} · ${i.settings || 0} saved setting${i.settings === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`) + row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false) + row('Running since', new Date(i.startedAt).toLocaleString(), false); diff --git a/server.js b/server.js index 2474fa0..b899b29 100644 --- a/server.js +++ b/server.js @@ -267,19 +267,27 @@ async function plexProxy(req, res) { } // ---- Storage health: is /data a real mounted volume, and what is in it? (so a wiped volume is obvious) +/** What is mounted at the data folder? Docker's own anonymous volumes look "mounted" but are lost when the container is recreated. */ +function classifyMount(mountinfoLine) { + const root = (mountinfoLine || '').split(' ')[3] || ''; + const vol = /\/volumes\/([^/]+)\/_data/.exec(root); + if (vol) return /^[0-9a-f]{64}$/.test(vol[1]) ? { kind: 'anonymous', name: vol[1].slice(0, 12) } : { kind: 'named', name: vol[1] }; + return { kind: 'folder', name: root }; +} function storageInfo() { const fs = require('fs'), path = require('path'); const dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); - let mounted = null; // null = cannot tell (not Linux) - try { mounted = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').some(l => l.split(' ')[4] === dir); } catch {} + let mounted = null, source = null; // null = cannot tell (not Linux) + try { const l = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').find(x => x.split(' ')[4] === dir); mounted = !!l; if (l) source = classifyMount(l); } catch {} let writable = true; try { const f = path.join(dir, '.write-test'); fs.writeFileSync(f, 'x'); fs.unlinkSync(f); } catch { writable = false; } const count = (f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { return 0; } }; - return { dir, mounted, writable, passkeys: count('passkeys.json', j => (j.creds || []).length), calendarLinks: count('calendars.json', j => j.length), + return { dir, mounted, source, writable, passkeys: count('passkeys.json', j => (j.creds || []).length), calendarLinks: count('calendars.json', j => j.length), events: count('events.json', j => j.length), settings: count('settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(dir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET, startedAt: STARTED }; } const STARTED = Date.now(); -{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted volume: ${i.mounted === null ? 'unknown' : i.mounted ? 'yes' : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); +{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); + if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.'); if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); } if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.');