summaryrefslogtreecommitdiff
path: root/modules/core/module-system.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/module-system.js')
-rw-r--r--modules/core/module-system.js202
1 files changed, 172 insertions, 30 deletions
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index 43bb0b1..ef37b11 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -35,6 +35,84 @@
'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint',
};
}
+
+ // ── doctor measurement helpers ──────────────────────────────────────
+ // UTF-8 byte length of a string. Blob is the reference implementation
+ // (Node 17+/every target browser), TextEncoder the browser fallback, and a
+ // code-point walk the last resort so a stripped-down WebView still gets a
+ // number instead of a thrown command.
+ _utf8Bytes(str) {
+ if (typeof str !== 'string' || str.length === 0) return 0;
+ try {
+ if (typeof Blob === 'function') return new Blob([str]).size;
+ } catch (e) {}
+ try {
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(str).length;
+ } catch (e) {}
+ let n = 0;
+ for (let i = 0; i < str.length; i++) {
+ const cp = str.codePointAt(i);
+ if (cp > 0xffff) i++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ }
+
+ // Sum of UTF-8 bytes of every file's content under `root`. This is the unit
+ // `vfs-size` reports, without JSON.stringify's braces/quoting overhead and
+ // without the old `chars * 2` fudge. Depth- and node-capped so a malformed
+ // or cyclic tree cannot hang the command.
+ _vfsContentBytes(root) {
+ let bytes = 0, nodes = 0;
+ const walk = (node, depth) => {
+ if (!node || typeof node !== 'object' || depth > 64) return;
+ if (node.type === 'file') {
+ if (typeof node.content === 'string') bytes += this._utf8Bytes(node.content);
+ return;
+ }
+ const kids = node.content;
+ if (!kids || typeof kids !== 'object') return;
+ for (const name of Object.keys(kids)) {
+ if (++nodes > 20000) return;
+ walk(kids[name], depth + 1);
+ }
+ };
+ walk(root, 0);
+ return bytes;
+ }
+
+ // Live reachability probe. The legacy `onLine` boolean is not consulted anywhere:
+ // in Android WebView it reports false while the page is fully connected, which is
+ // exactly how `status` used to tell working users they were offline.
+ async _probeNetwork() {
+ try {
+ if (typeof fetch !== 'function') return { state: 'unprobed', detail: 'fetch unavailable' };
+ } catch (e) {
+ return { state: 'unprobed', detail: 'fetch unavailable' };
+ }
+ let ctl = null, timer = null;
+ try { if (typeof AbortController === 'function') ctl = new AbortController(); } catch (e) {}
+ try {
+ if (ctl) timer = setTimeout(() => { try { ctl.abort(); } catch (e) {} }, 4000);
+ const resp = await fetch('./', {
+ method: 'HEAD',
+ cache: 'no-store',
+ signal: ctl ? ctl.signal : undefined,
+ });
+ if (!resp) return { state: 'unreachable', detail: 'empty response' };
+ if (resp.ok) return { state: 'reachable', detail: 'HTTP ' + resp.status };
+ return { state: 'http-' + resp.status, detail: 'HTTP ' + resp.status };
+ } catch (e) {
+ const aborted = e && (e.name === 'AbortError' || e.name === 'TimeoutError');
+ return {
+ state: 'unreachable',
+ detail: aborted ? 'timed out after 4s' : ((e && e.message) || 'request failed'),
+ };
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ }
+
registerCommands() {
this.commands['help'] = (args) => this.handleHelp(args);
this.commands['echo'] = (args) => this.terminal.print(args.join(' '));
@@ -83,7 +161,10 @@
}, 500);
this.commands['status'] = () => {
this.terminal.print(`System: WebX11 ${window.NEWOS4_VERSION || 'v4.0'}`);
- this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`);
+ // The legacy `onLine` boolean is NOT evidence of reachability: Android
+ // WebView reports false on a fully connected page, and true on a captive
+ // portal. Say so, and point at the command that actually probes.
+ this.terminal.print('Network: unknown — run doctor for a live probe');
this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`);
this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`);
this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`);
@@ -110,7 +191,7 @@
this.terminal.print(` Build: ${buildDate}`);
this.terminal.print(` Cache key: ${cacheKey}`);
this.terminal.print(` Browser: ${ua}`);
- this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ this.terminal.print(` Network: unknown — run doctor for a live probe`);
};
// v4.5.43: uptime — time since page load + last save time
@@ -166,40 +247,102 @@
};
// v4.5.43: doctor — run all health checks, rank issues, show top 3
- this.commands['doctor'] = () => {
+ // v4.6.x: the legacy `onLine` boolean is GONE from this file. It reports false
+ // on a fully connected Android WebView, which is how `status`/`doctor` used to
+ // announce an outage on a working page. Reachability now comes from a real HEAD
+ // probe, and every size is measured in the unit the store actually charges for.
+ this.commands['doctor'] = async () => {
const issues = [];
const fs = this.terminal.fs;
-
- // Check 1: Network
- if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' });
+ const MB = 1024 * 1024;
+
+ // Check 1: Network — live probe, 4s budget
+ const net = await this._probeNetwork();
+ if (net.state === 'unreachable') {
+ issues.push({ severity: 'high', msg: `Network: unreachable (HEAD ./ — ${net.detail})` });
+ } else if (net.state.indexOf('http-') === 0) {
+ // A non-ok HEAD is not proof the page is broken (some servers answer
+ // 405 to HEAD); report the status and let the user judge.
+ issues.push({ severity: 'medium', msg: `Network: HTTP probe to ./ returned ${net.state} (page may still work)` });
+ }
// Check 2: Ollama
if (!this.terminal.ollamaManager.connected) {
issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' });
}
- // Check 3: VFS size
+ // Origin quota — context only. It is the whole-origin quota (Cache Storage
+ // included, typically hundreds of MB to GBs). localStorage has its own
+ // ~5 MB per-origin cap, so this number must NEVER set a severity: a 4.5 MB
+ // localStorage is 96% of its cap and 0.2% of a 2 GB origin quota.
+ let originUsage = 0, originQuota = 0;
+ try {
+ if (typeof navigator !== 'undefined' && navigator.storage &&
+ typeof navigator.storage.estimate === 'function') {
+ const est = await navigator.storage.estimate();
+ if (est && typeof est.quota === 'number' && est.quota > 0) {
+ originQuota = est.quota;
+ originUsage = typeof est.usage === 'number' ? est.usage : 0;
+ }
+ }
+ } catch (e) {}
+
+ // Check 3: VFS — UTF-8 bytes of file content (the unit vfs-size reports),
+ // never JSON.stringify(root).length and never "chars × 2".
+ let vfsBytes = null;
+ try { vfsBytes = this._vfsContentBytes(fs.root); } catch (e) { vfsBytes = null; }
+
+ // Check 4: localStorage — JS strings are UTF-16, so each key and value is
+ // charged 2 bytes per code unit. Σ (key.length + value.length) × 2 is the
+ // real storage cost, not a fudge factor.
+ let lsBytes = 0, lsKeys = 0;
try {
- const vfsBytes = JSON.stringify(fs.root).length;
- if (vfsBytes > 524288) issues.push({ severity: 'high', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 512KB limit` });
- else if (vfsBytes > 262144) issues.push({ severity: 'medium', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 256KB (consider reset-vfs)` });
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k) continue;
+ lsKeys++;
+ lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
+ }
} catch (e) {}
- // Check 4: Heap
+ // VFS thresholds: a VFS only threatens the browser budget when it is BOTH
+ // large on its own and the bulk of what is actually stored.
+ if (vfsBytes !== null) {
+ const vfsMB = vfsBytes / MB;
+ const share = lsBytes > 0 ? vfsBytes / lsBytes : 0;
+ const budget = lsBytes > 0
+ ? `, ${(share * 100).toFixed(0)}% of the ${(lsBytes / MB).toFixed(1)} MB localStorage budget`
+ : ', no localStorage copy measured';
+ if (vfsBytes > 3 * MB && (lsBytes === 0 || share > 0.8)) {
+ issues.push({ severity: 'high', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} — close to the ~5 MB per-origin cap` });
+ } else if (vfsBytes > 2 * MB) {
+ issues.push({ severity: 'medium', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} (consider deleting stale checkpoints)` });
+ }
+ }
+
+ // localStorage thresholds: the cap is ~5 MB per origin, whatever
+ // navigator.storage.estimate() says the whole origin may use.
+ if (lsBytes > 4 * MB) {
+ issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — approaching the ~5 MB per-origin cap` });
+ } else if (lsBytes > 3 * MB) {
+ issues.push({ severity: 'medium', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — past half of the ~5 MB per-origin cap` });
+ }
+
+ // Check 5: Heap
if (performance.memory) {
const heapMB = performance.memory.usedJSHeapSize / 1048576;
if (heapMB > 200) issues.push({ severity: 'high', msg: `Heap: ${heapMB.toFixed(0)}MB > 200MB` });
else if (heapMB > 100) issues.push({ severity: 'medium', msg: `Heap: ${heapMB.toFixed(0)}MB > 100MB` });
}
- // Check 5: Logs
+ // Check 6: Logs
try {
const logContent = fs.readFile('/logs/current.log') || '';
const logLines = logContent.split('\n').filter(l => l).length;
if (logLines > 500) issues.push({ severity: 'low', msg: `Logs: ${logLines} lines (consider logrotate)` });
} catch (e) {}
- // Check 6: Errors in last 100 log lines
+ // Check 7: Errors in last 100 log lines
try {
const logContent = fs.readFile('/logs/current.log') || '';
const recent = logContent.split('\n').filter(l => l).slice(-100);
@@ -208,16 +351,6 @@
else if (errorCount > 0) issues.push({ severity: 'medium', msg: `Errors: ${errorCount} errors in last 100 log lines` });
} catch (e) {}
- // Check 7: localStorage quota
- let lsBytes = 0;
- try {
- for (let i = 0; i < localStorage.length; i++) {
- const k = localStorage.key(i);
- if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
- }
- } catch (e) {}
- if (lsBytes > 4 * 1024 * 1024) issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes/1024/1024).toFixed(1)}MB approaching 5MB quota` });
-
// Check 8: Disabled modules
let disabled = [];
try { disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {}
@@ -226,6 +359,11 @@
}
this.terminal.print('🩺 Doctor Report', 'header');
+ this.terminal.print(` Measured: VFS ${vfsBytes === null ? 'unavailable' : (vfsBytes / MB).toFixed(2) + ' MB UTF-8'} · localStorage ${(lsBytes / MB).toFixed(2)} MB UTF-16 in ${lsKeys} keys`, 'info');
+ this.terminal.print(` Network: ${net.state === 'reachable' ? 'reachable (' + net.detail + ')' : net.state + (net.detail ? ' — ' + net.detail : '')}`, 'info');
+ if (originQuota > 0) {
+ this.terminal.print(` Origin usage ${(originUsage / MB).toFixed(1)} MB of ~${(originQuota / MB).toFixed(0)} MB quota (whole origin, mostly Cache Storage — localStorage has its own ~5 MB cap)`, 'info');
+ }
if (issues.length === 0) {
this.terminal.print(' ✅ All systems healthy', 'success');
return;
@@ -234,20 +372,21 @@
const sevOrder = { high: 0, medium: 1, low: 2 };
issues.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
this.terminal.print(` Found ${issues.length} issue(s):`, 'warning');
- for (const i of issues.slice(0, 5)) {
+ for (const i of issues.slice(0, 3)) {
const icon = i.severity === 'high' ? '🔴' : i.severity === 'medium' ? '🟡' : '🟢';
this.terminal.print(` ${icon} [${i.severity.toUpperCase()}] ${i.msg}`, i.severity === 'high' ? 'error' : 'warning');
}
- if (issues.length > 5) this.terminal.print(` ... and ${issues.length - 5} more`, 'info');
+ if (issues.length > 3) this.terminal.print(` ... and ${issues.length - 3} more`, 'info');
this.terminal.print('', '');
this.terminal.print(' Recommended actions:', 'info');
- if (issues.some(i => i.msg.startsWith('Network'))) this.terminal.print(' → Check internet connection');
+ if (issues.some(i => i.msg.startsWith('Network: unreachable'))) this.terminal.print(' → Check connectivity; reload before trusting this probe');
+ if (issues.some(i => i.msg.startsWith('Network: HTTP probe'))) this.terminal.print(' → Server answered the probe with an error status — check the server, not the network');
if (issues.some(i => i.msg.startsWith('Ollama'))) this.terminal.print(' → Check Ollama is running (ollama serve)');
- if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Run reset-vfs --confirm to clear or checkpoint/rollback');
+ if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Delete stale snapshots (checkpoint-list, checkpoint-del <name>) or rollback; do NOT reset-vfs');
if (issues.some(i => i.msg.startsWith('Heap'))) this.terminal.print(' → Refresh the page to clear memory');
if (issues.some(i => i.msg.startsWith('Logs'))) this.terminal.print(' → Run logrotate');
if (issues.some(i => i.msg.startsWith('Errors'))) this.terminal.print(' → Run errors to see details, or bug-report to save state');
- if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Run factory-reset --confirm (last resort)');
+ if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Free space by deleting stale VFS entries or checkpoints; do NOT factory-reset');
};
// v4.5.43: bug-report — generate markdown report to /tmp/
@@ -261,7 +400,7 @@
lines.push('## System');
lines.push(`- Version: ${window.NEWOS4_VERSION || 'unknown'}`);
lines.push(`- Cache key: ${localStorage.getItem('webx11_cache_ver') || 'unknown'}`);
- lines.push(`- Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ lines.push(`- Online: unknown (not probed; run doctor)`);
lines.push(`- Browser: ${navigator.userAgent}`);
lines.push(`- Screen: ${screen.width}x${screen.height}`);
lines.push(`- Heap: ${performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'}`);
@@ -325,7 +464,10 @@
version: window.NEWOS4_VERSION || 'unknown',
url: location.href,
ua: navigator.userAgent,
- online: navigator.onLine,
+ // Legacy key, kept so stored reports keep their shape. The old onLine
+ // boolean lied in Android WebView, so null = "not asserted".
+ online: null,
+ network: 'unknown',
modules: Object.keys(this.terminal.moduleManager.modules).length,
errors: [],
};