From 759002522a69709fd0402c9698b4cba0e7a39e4b Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 11:19:03 +0200 Subject: [PATCH 1/7] feat(maintenance,dashboard): revdep-rebuild panel and disk-space forecast tile - Add revdep-rebuild pretend + run flow under Maintenance (same approval pattern as preserved-rebuild) - Add disk-space forecast dashboard tile showing distfiles / binpkgs / /var/tmp/portage with human-readable sizes - Wire all new commands through the three security whitelists Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + backend/arbor/action_security.py | 3 + backend/arbor/authorization.py | 3 + backend/arbor/main.py | 26 +++++++ backend/daemon/main.py | 64 ++++++++++++++++- frontend/alpine/app.js | 82 ++++++++++++++++++++- frontend/alpine/index.html | 52 +++++++++++--- frontend/alpine/style.css | 118 ++++++++++++++++++++++++++++++- 8 files changed, 334 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 9b264c3..11757f4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ ARBOR_ARCHITECTURE_ROADMAP.md ONLINE_AUTH_HARDENING_PLAN.md PHASE1_PROGRESS.md FEATURE_PROPOSALS.md +MIGRATE_RESPONSIVE.md diff --git a/backend/arbor/action_security.py b/backend/arbor/action_security.py index 6604bbe..99e38e0 100644 --- a/backend/arbor/action_security.py +++ b/backend/arbor/action_security.py @@ -39,12 +39,14 @@ "glsa_list", "eclean_pretend", "snapshot_export", + "disk_usage", } _PRETEND_COMMANDS = { "emerge_pretend", "emerge_uninstall_pretend", "emerge_depclean_pretend", + "revdep_rebuild_pretend", } _APPROVAL_REQUIRED_COMMANDS = { @@ -60,6 +62,7 @@ "history_purge", "eclean_run", "snapshot_import", + "revdep_rebuild", } _TARGET_KEYS = ("atom", "name", "cfg_file", "job_id") diff --git a/backend/arbor/authorization.py b/backend/arbor/authorization.py index 47b3f77..95ef992 100644 --- a/backend/arbor/authorization.py +++ b/backend/arbor/authorization.py @@ -72,6 +72,9 @@ class StepUpRequiredError(PermissionError): "totp_enroll_begin", "totp_enroll_confirm", "totp_disable", + "revdep_rebuild_pretend", + "revdep_rebuild", + "disk_usage", } _ROLE_ALLOWED_CLASSES = { diff --git a/backend/arbor/main.py b/backend/arbor/main.py index f579de4..e90fd6a 100644 --- a/backend/arbor/main.py +++ b/backend/arbor/main.py @@ -805,6 +805,27 @@ async def ws_emerge_preserved_rebuild( ) +@app.websocket("/ws/emerge/revdep-pretend") +async def ws_revdep_pretend(websocket: WebSocket): + await _ws_job_cmd(websocket, "revdep_rebuild_pretend", {}) + + +@app.websocket("/ws/emerge/revdep-rebuild") +async def ws_revdep_rebuild( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "revdep_rebuild", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + @app.websocket("/ws/emerge/world-pretend") async def ws_emerge_world_pretend(websocket: WebSocket): await _ws_job_cmd(websocket, "world_updates", {}) @@ -996,6 +1017,11 @@ async def pkg_stats(auth: Auth): return data +@app.get("/api/disk-usage") +async def disk_usage(auth: Auth): + return await query_one("disk_usage") + + @app.get("/api/analytics/compile-time-by-category") async def analytics_compile_time(auth: Auth): """ diff --git a/backend/daemon/main.py b/backend/daemon/main.py index d9f9ddc..6641609 100644 --- a/backend/daemon/main.py +++ b/backend/daemon/main.py @@ -111,6 +111,9 @@ "eclean_run", "snapshot_export", "snapshot_import", + "revdep_rebuild_pretend", + "revdep_rebuild", + "disk_usage", } # --------------------------------------------------------------------------- @@ -2158,7 +2161,7 @@ def _canonical_approval_args(action_cmd: str, args: dict | None) -> dict: if action_cmd == "emerge_world_update": opts = ",".join(_parse_opts(str(data.get("opts", "")), _UPDATE_OPTS)) return {"opts": opts} if opts else {} - if action_cmd in {"emerge_depclean", "emerge_preserved_rebuild", "emerge_sync"}: + if action_cmd in {"emerge_depclean", "emerge_preserved_rebuild", "emerge_sync", "revdep_rebuild"}: return {} if action_cmd == "overlay_sync": return {"name": str(data.get("name", "")).strip()} @@ -2755,6 +2758,62 @@ async def cmd_emerge_preserved_rebuild(_args): yield item +async def cmd_revdep_rebuild_pretend(_args): + async for item in _start_background_job( + "@revdep-pretend", + ["revdep-rebuild", "--pretend"], + kind="revdep-pretend", + action_cmd="revdep_rebuild_pretend", + action_args={}, + ): + yield item + + +async def cmd_revdep_rebuild(_args): + approval_error = await in_thread( + _require_approval, + "revdep_rebuild", + _approval_payload("revdep_rebuild", _args), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "@revdep-rebuild", + ["revdep-rebuild"], + kind="revdep-rebuild", + action_cmd="revdep_rebuild", + action_args={}, + ): + yield item + + +def _disk_usage() -> dict: + paths = { + "distfiles": "/var/cache/distfiles", + "binpkgs": "/var/cache/binpkgs", + "tmp_portage": "/var/tmp/portage", + } + result = {} + for key, path in paths.items(): + total = 0 + try: + for dirpath, _dirs, files in os.walk(path): + for f in files: + try: + total += os.path.getsize(os.path.join(dirpath, f)) + except OSError: + pass + except OSError: + pass + result[key] = total + return result + + +async def cmd_disk_usage(_args): + yield await in_thread(_disk_usage) + + async def cmd_emerge_sync(_args): approval_error = await in_thread(_require_approval, "emerge_sync", _approval_payload("emerge_sync", _args)) if approval_error: @@ -3731,6 +3790,9 @@ async def cmd_snapshot_import(args): "eclean_run": cmd_eclean_run, "snapshot_export": cmd_snapshot_export, "snapshot_import": cmd_snapshot_import, + "revdep_rebuild_pretend": cmd_revdep_rebuild_pretend, + "revdep_rebuild": cmd_revdep_rebuild, + "disk_usage": cmd_disk_usage, } diff --git a/frontend/alpine/app.js b/frontend/alpine/app.js index 10011d5..6a15ca3 100644 --- a/frontend/alpine/app.js +++ b/frontend/alpine/app.js @@ -105,6 +105,15 @@ await _post('/auth/step-up/ensure', {}) } + function _fmtBytes(b) { + if (!b) return '0 B' + const gb = b / 1024 ** 3 + if (gb >= 1) return gb.toFixed(1) + ' GB' + const mb = b / 1024 ** 2 + if (mb >= 1) return mb.toFixed(0) + ' MB' + return (b / 1024).toFixed(0) + ' KB' + } + const api = { authBackend: () => _get('/auth/backend'), authSession: () => _get('/auth/session'), @@ -131,6 +140,7 @@ newsRead: (id) => _post('/news/read', { id }), newsReadAll: () => _post('/news/read-all', {}), glsa: () => _get('/glsa'), + diskUsage: () => _get('/disk-usage'), snapshotImport: (file) => { const fd = new FormData(); fd.append('file', file) return fetch('/api/snapshot/import', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken() }, body: fd }) @@ -249,7 +259,7 @@ // and read-only attaches do not. const _MUTATING_EMERGE_CMDS = new Set([ 'install', 'uninstall', 'autounmask', - 'world-update', 'depclean', 'preserved-rebuild', 'sync', 'eclean', + 'world-update', 'depclean', 'preserved-rebuild', 'sync', 'eclean', 'revdep-rebuild', ]) function wsEmerge(cmd, atom, onMsg, extra = {}) { @@ -350,7 +360,7 @@ if (request.action_cmd === 'emerge_uninstall' && args.atom) { return { view: 'uninstall', param: _approvalAtomKey(args.atom) } } - if (['emerge_world_update', 'emerge_depclean', 'emerge_preserved_rebuild', 'emerge_sync'].includes(request.action_cmd)) { + if (['emerge_world_update', 'emerge_depclean', 'emerge_preserved_rebuild', 'emerge_sync', 'revdep_rebuild'].includes(request.action_cmd)) { return { view: 'updates', param: null } } if (['overlay_sync', 'overlay_add', 'overlay_remove'].includes(request.action_cmd)) { @@ -1054,6 +1064,7 @@ { id: 'updates-check', label: 'Check' }, { id: 'updates-world', label: '@world' }, { id: 'updates-preserved', label: 'Preserved' }, + { id: 'updates-revdep', label: 'Revdep' }, { id: 'updates-depclean', label: 'Depclean' }, { id: 'updates-clean', label: 'Clean' }, ] @@ -1212,6 +1223,7 @@ recentHistory: [], newsData: null, glsaData: null, + diskUsage: null, error: null, _timer: null, init() { @@ -1230,7 +1242,7 @@ async _load() { try { this.error = null - const [statusRes, statsRes, pkgStatsRes, compileCatsRes, jobsRes, historyRes, newsRes, glsaRes] = await Promise.allSettled([ + const [statusRes, statsRes, pkgStatsRes, compileCatsRes, jobsRes, historyRes, newsRes, glsaRes, diskUsageRes] = await Promise.allSettled([ api.status(), api.stats(), api.pkgStats(), @@ -1239,6 +1251,7 @@ jobHistory.list(12, 0, ''), api.news(), api.glsa(), + api.diskUsage(), ]) if (statusRes.status !== 'fulfilled') throw statusRes.reason this.status = statusRes.value @@ -1249,6 +1262,7 @@ this.recentHistory = historyRes.status === 'fulfilled' && Array.isArray(historyRes.value?.items) ? historyRes.value.items : [] this.newsData = newsRes.status === 'fulfilled' ? newsRes.value : null this.glsaData = glsaRes.status === 'fulfilled' ? glsaRes.value : null + this.diskUsage = diskUsageRes.status === 'fulfilled' ? diskUsageRes.value : null } catch(e) { this.error = e.message } finally { this.$nextTick(() => this._renderCompileCats()) } }, @@ -1505,6 +1519,20 @@ tone: 'muted', }, ] + // Disk usage (distfiles + binpkgs + tmp) + if (this.diskUsage) { + const du = this.diskUsage + const dist = du.distfiles || 0 + const bin = du.binpkgs || 0 + const tmp = du.tmp_portage || 0 + items.push({ + key: 'disk-usage', + label: 'Portage cache', + value: _fmtBytes(dist + bin + tmp), + detail: 'distfiles ' + _fmtBytes(dist) + ' · binpkgs ' + _fmtBytes(bin) + ' · tmp ' + _fmtBytes(tmp), + tone: 'muted', + }) + } // Unread news const unreadNews = Array.isArray(this.newsData) ? this.newsData.filter(n => n.unread).length : null if (unreadNews !== null) { @@ -3599,6 +3627,16 @@ ${labels} 'This is typically follow-up work after updates changed linked libraries.', ], }, + revdep: { + id: 'revdep', + title: 'Revdep rebuild', + summary: 'Scan and rebuild packages with broken library dependencies.', + risk: 'Low. Only rebuilds packages that reference missing or changed shared libraries.', + notes: [ + 'Run the pretend phase first to see which packages will be rebuilt.', + 'Useful after library upgrades that left dependent packages with broken linkage.', + ], + }, depclean: { id: 'depclean', title: 'Depclean', @@ -3630,6 +3668,7 @@ ${labels} worldUpdate: mkOp(false), depclean: { ...mkOp(false), dcStep: 'idle' }, preserved: mkOp(false), + revdep: mkOp(false), clean: { ...mkOp(false), cleanStep: 'idle', cleanTarget: 'dist' }, pkgStats: null, selectedAction: 'worldPretend', @@ -3704,6 +3743,7 @@ ${labels} if (id === 'worldPretend') return 'emerge -uDN --pretend @world' if (id === 'sync') return 'emaint sync -a' if (id === 'preserved') return 'emerge @preserved-rebuild' + if (id === 'revdep') return 'revdep-rebuild' if (id === 'depclean') return 'emerge --depclean' if (id === 'clean') return this.clean.cleanTarget === 'dist' ? 'eclean-dist' : 'eclean-pkg' return '' @@ -3810,6 +3850,7 @@ ${labels} if (cmd === 'emerge_world_update') return this._startWorldUpdate() if (cmd === 'emerge_depclean') return this._startDepclean() if (cmd === 'emerge_preserved_rebuild') return this._startPreserved() + if (cmd === 'revdep_rebuild') return this._startRevdep() }) op.expanded = true op.running = false @@ -3837,6 +3878,10 @@ ${labels} this.selectAction('preserved') op = this.preserved onApproved = () => this._startPreserved() + } else if (request.action_cmd === 'revdep_rebuild') { + this.selectAction('revdep') + op = this.revdep + onApproved = () => this._startRevdep() } else { return false } @@ -4049,6 +4094,37 @@ ${labels} if (msg.done) { this.preserved.running = false; this.preserved.rc = msg.returncode ?? null; this.preserved.ws = null; _scopedStorageRemove(_JOB_META.preserved.storage) } }) }, + startRevdepPretend() { + this.selectAction('revdep') + this.revdep.lines = []; this.revdep.rc = null; this.revdep.running = true; this.revdep.expanded = true + this.revdep.ws = wsGlobalEmerge('revdep-pretend', (msg) => { + if (msg.job_id) return + if (msg.line !== undefined) this._appendLine(this.revdep, 'revdepTerm', msg.line) + if (msg.done) { this.revdep.running = false; this.revdep.rc = msg.returncode ?? null; this.revdep.ws = null } + }) + }, + startRevdep() { + this.selectAction('revdep') + this._startRevdep() + }, + async _startRevdep() { + this.revdep.lines = []; this.revdep.rc = null; this.revdep.running = true; this.revdep.expanded = true + let approval + try { + approval = await this._requestOpApproval(this.revdep, 'revdep_rebuild', {}) + } catch (e) { + this.revdep.lines = ['Error: ' + e.message] + return + } + if (!approval) return + clearApprovalState(this.revdep) + this.revdep.running = true + this.revdep.ws = wsGlobalEmerge('revdep-rebuild', (msg) => { + if (msg.job_id) return + if (msg.line !== undefined) this._appendLine(this.revdep, 'revdepTerm', msg.line) + if (msg.done) { this.revdep.running = false; this.revdep.rc = msg.returncode ?? null; this.revdep.ws = null } + }, { approval_request_id: approval.request_id }) + }, } } diff --git a/frontend/alpine/index.html b/frontend/alpine/index.html index 556c31e..9df0dcc 100644 --- a/frontend/alpine/index.html +++ b/frontend/alpine/index.html @@ -635,6 +635,7 @@

Loading…

+
@@ -652,6 +653,7 @@

+

@@ -687,6 +689,7 @@

USE Flags

Loading USE flags…

+
@@ -718,6 +721,7 @@

USE Flags

+

@@ -755,23 +759,23 @@

-
Overrides ?
+
Overrides ?
-
Installed supporting ?
+
Installed supporting ?
-
Built enabled ?
+
Built enabled ?
-
Built disabled ?
+
Built disabled ?
-
Mismatch ?
+
Mismatch ?
@@ -804,7 +808,7 @@

Configuration state

-

Package-specific overrides ?

+

Package-specific overrides ?

Explicit package.use state for this flag. These rows may include packages that are not installed.

@@ -855,7 +859,7 @@
-

Installed packages built with this flag enabled ?

+

Installed packages built with this flag enabled ?

Installed packages whose IUSE supports this flag and whose current build has it enabled.

@@ -882,7 +886,7 @@

Installed packages built with this flag enabled
-

Installed packages supporting this flag but built without it ?

+

Installed packages supporting this flag but built without it ?

Installed packages whose IUSE supports this flag but whose current build does not enable it.

@@ -934,12 +938,13 @@

Search Portage Tree

- +

Searching…

No results for ""

+
@@ -956,6 +961,7 @@

Search Portage Tree

+
@@ -1109,6 +1115,34 @@

Maintenance

+
+
+
+
+ Revdep rebuild + +
+ revdep-rebuild +

+
+
+ + + +
+
+
+ +
+
Scan and rebuild packages with broken library dependencies.
+
+
+
diff --git a/frontend/alpine/style.css b/frontend/alpine/style.css index adfac61..bd0463e 100644 --- a/frontend/alpine/style.css +++ b/frontend/alpine/style.css @@ -41,6 +41,11 @@ --chart-4: #8794a1; --chart-5: #8f7c73; + /* Responsive tokens */ + --touch-target: 44px; + --pad-mobile: 1rem; + --pad-desktop: 2rem; + /* Backward-compatible aliases for the existing stylesheet. */ --bg-app: var(--background); --bg-nav: var(--nav); @@ -164,7 +169,7 @@ nav li.active button { color: #7ee787; border-left: 2px solid #7ee787; } border: 1px solid #30363d; border-radius: 8px; padding: 2.5rem; - width: 340px; + width: min(340px, calc(100% - 2rem)); } .card h1 { font-size: 1.8rem; color: #7ee787; margin-bottom: 0.25rem; } @@ -3236,7 +3241,7 @@ h2 { color: #e6edf3; margin-bottom: 0.25rem; } flex-wrap: wrap; } .mv-title { color: #c9d1d9; font-size: 0.88rem; font-weight: bold; } -.mv-cmd { color: #6e7681; font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; } +.mv-cmd { color: #6e7681; font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; overflow-wrap: anywhere; word-break: break-all; } .mv-card-summary { color: var(--text-muted); font-size: 0.76rem; @@ -4705,3 +4710,112 @@ input:focus, .sn-import-result { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .sn-result-ok { font-size: 0.82rem; color: #88a784; font-weight: 600; } .sn-result-backup { font-size: 0.78rem; color: #6e7681; font-family: monospace; } + +/* ── Responsive additions ────────────────────────────────────────────────── */ + +/* Search input (replaces inline style="width:300px") */ +.pkg-search-input { max-width: 300px; width: 100%; } + +/* pkg-toolbar-copy: cap flex-basis so it doesn't overflow narrow viewports */ +.pkg-toolbar-copy { flex-basis: min(28rem, 100%); } + +/* Table scroll containers */ +.pkg-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } + +/* Touch parity: :active mirrors :hover on clickable table rows */ +.pkg-table tr.clickable:active td { background: #161b22; } + +/* ── Tablet (641px – 960px) ──────────────────────────────────────────────── */ +@media (min-width: 641px) and (max-width: 960px) { + .pkg-explorer-layout { + grid-template-columns: 1fr; + } + + .mv-layout { + grid-template-columns: 1fr; + } + + .ufi-sidebar { + position: static; + max-height: none; + } + + .pkg-toolbar { + flex-wrap: wrap; + } + + .pkg-toolbar-copy, + .pkg-toolbar-controls { + flex: 1 1 45%; + } +} + +/* ── Mobile (≤ 640px) ────────────────────────────────────────────────────── */ +@media (max-width: 640px) { + /* Login card */ + .card { + width: min(340px, calc(100% - 2rem)); + padding: 1.75rem 1.25rem; + } + + /* Toolbar inputs */ + .pkg-toolbar input, + .pkg-search-input { width: 100%; max-width: none; } + + .pkg-toolbar { + flex-wrap: wrap; + gap: 0.65rem; + } + + .pkg-toolbar h2 { flex: 0 0 100%; } + + .pkg-toolbar-copy, + .pkg-toolbar-controls { + flex: 1 1 100%; + } + + /* Touch targets — primary interactive elements only, not compact toggles */ + .app-primary-link, + .app-context-link, + .app-logout, + .ufi-term-help, + .mv-btn-run, + .nw-mark-all-btn { + min-height: var(--touch-target); + min-width: var(--touch-target); + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* Maintenance: full-width single column, hide context panel */ + .mv-layout { grid-template-columns: 1fr; } + .mv-context-panel { display: none; } + + /* Maintenance card header: stack vertically on small screens */ + .mv-card-header { + flex-direction: column; + gap: 0.5rem; + } + + .mv-card-actions { + align-self: flex-end; + } + + /* Tooltip touch widget */ + .ufi-term-help { position: relative; } + .tip { + position: absolute; + z-index: 200; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.82rem; + max-width: 240px; + line-height: 1.5; + color: var(--text); + white-space: normal; + pointer-events: none; + } +} From 33e90f921490ba506663c95892d68950a2b647e3 Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 11:26:11 +0200 Subject: [PATCH 2/7] fix(dashboard): use auto-fit grid for summary strip to show all tiles Fixed repeat(4) grid that was clipping disk usage, news, and GLSA tiles beyond the 4th column. Co-Authored-By: Claude Sonnet 4.6 --- frontend/alpine/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/alpine/style.css b/frontend/alpine/style.css index bd0463e..9692f18 100644 --- a/frontend/alpine/style.css +++ b/frontend/alpine/style.css @@ -1908,7 +1908,7 @@ h2 { color: #e6edf3; margin-bottom: 0.25rem; } .dash-summary-strip { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); border: 1px solid var(--border); border-radius: 6px; background: var(--surface); From eb7675b11ac01bfc04e3df1f4574af107bc33cf7 Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 11:33:08 +0200 Subject: [PATCH 3/7] fix(disk-usage): replace os.walk with du -sb for fast disk size calculation os.walk + getsize on /var/cache/distfiles with thousands of files caused the daemon to hang for 60+ seconds, blocking the HTTP response. du -sb returns the same answer in milliseconds via the filesystem block counts. Co-Authored-By: Claude Sonnet 4.6 --- backend/daemon/main.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/daemon/main.py b/backend/daemon/main.py index 6641609..53f34ac 100644 --- a/backend/daemon/main.py +++ b/backend/daemon/main.py @@ -2789,6 +2789,7 @@ async def cmd_revdep_rebuild(_args): def _disk_usage() -> dict: + import subprocess paths = { "distfiles": "/var/cache/distfiles", "binpkgs": "/var/cache/binpkgs", @@ -2796,17 +2797,13 @@ def _disk_usage() -> dict: } result = {} for key, path in paths.items(): - total = 0 try: - for dirpath, _dirs, files in os.walk(path): - for f in files: - try: - total += os.path.getsize(os.path.join(dirpath, f)) - except OSError: - pass - except OSError: - pass - result[key] = total + out = subprocess.check_output( + ["du", "-sb", path], stderr=subprocess.DEVNULL, timeout=10 + ).decode() + result[key] = int(out.split()[0]) + except Exception: + result[key] = 0 return result From a7cbbf32a0f18b50cccf977355e254835ab593df Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 11:43:02 +0200 Subject: [PATCH 4/7] fix(storage-stats): rename /api/disk-usage to /api/storage-stats Brave's ad/tracker filter lists block URLs containing "usage", causing the endpoint to be silently dropped on Brave Android. Co-Authored-By: Claude Sonnet 4.6 --- backend/arbor/main.py | 4 ++-- frontend/alpine/app.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/arbor/main.py b/backend/arbor/main.py index e90fd6a..9c249c6 100644 --- a/backend/arbor/main.py +++ b/backend/arbor/main.py @@ -1017,8 +1017,8 @@ async def pkg_stats(auth: Auth): return data -@app.get("/api/disk-usage") -async def disk_usage(auth: Auth): +@app.get("/api/storage-stats") +async def storage_stats(auth: Auth): return await query_one("disk_usage") diff --git a/frontend/alpine/app.js b/frontend/alpine/app.js index 6a15ca3..1c98de6 100644 --- a/frontend/alpine/app.js +++ b/frontend/alpine/app.js @@ -140,7 +140,7 @@ newsRead: (id) => _post('/news/read', { id }), newsReadAll: () => _post('/news/read-all', {}), glsa: () => _get('/glsa'), - diskUsage: () => _get('/disk-usage'), + diskUsage: () => _get('/storage-stats'), snapshotImport: (file) => { const fd = new FormData(); fd.append('file', file) return fetch('/api/snapshot/import', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken() }, body: fd }) From 5a913785fa324a5898bc0ec9c1762c2714bcb774 Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 18:10:32 +0200 Subject: [PATCH 5/7] feat(kernel): module/source cleanup, tab layout, Limine auto-update, beta label - Add /lib/modules cleanup: scan orphaned module dirs, show size, checkbox-select and remove with approval (kernel_modules_clean) - Add /usr/src source cleanup: checkbox-select inactive source dirs, show size via du -sb, remove with approval (kernel_src_clean); protect active symlink target from deletion - Extend _kernel_status() to include modules list and size per src_dir - Replace 8 kernel accordions with 4-tab layout (Overview / Sources / Bootloader / Build) with two-column grids for desktop; Build tab shows a pip dot when wizard is active - Sources tab: /usr/src dirs full-width on top, Portage and kernel.org side-by-side below - Add Limine bootloader auto-update wizard step (kernel_limine_config_auto_update): clones running entry, substitutes new kver in paths, inserts before first linux entry, creates timestamped backup - Fix default-entry dropdown: use x-effect + selectedIndex to avoid Alpine CSP x-for value-attribute timing bug; @change reads selectedIndex so number (not full label text) is stored - Mark kernel section as beta: nav label, in-page notice banner, README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + backend/arbor/action_security.py | 22 + backend/arbor/authorization.py | 22 + backend/arbor/main.py | 368 ++++++++- backend/daemon/main.py | 1291 ++++++++++++++++++++++++++++++ frontend/alpine/app.js | 933 ++++++++++++++++++++- frontend/alpine/index.html | 766 ++++++++++++++++++ frontend/alpine/style.css | 212 +++++ 8 files changed, 3612 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8e9df84..8c2227b 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,7 @@ Overlay add is disabled by default. To enable it, set `ARBOR_ENABLE_OVERLAY_ADD= - **Portage News** — reads GLEP 42 news items from the local tree, tracks read state per-item, shows an unread count badge on the dashboard - **GLSA advisories** — lists security advisories that affect the installed system via `glsa-check`, with severity badges, affected packages, and a quick-fix button - **Config snapshot** — export `/etc/portage/` (including the `make.profile` symlink) and world files as a zip; import with an automatic timestamped backup of the current config before applying +- **Kernel management** *(beta)* — full lifecycle wizard for source kernels: install or update via Portage with pretend preview and autounmask support; download vanilla kernels directly from kernel.org; browse and clean up `/usr/src/linux-*` source directories; switch the active symlink; 7-step manual build wizard (kernel config with optional `.config` copy from another source, `make modules_install`, `make install`, dracut initramfs generation, `@module-rebuild`, Limine bootloader auto-update); manage bootloaders including a Limine config editor; clean up old `/boot` files and orphaned `/lib/modules` directories; reboot with double confirmation (type `REBOOT` + approval); wizard state persisted in the browser across page reloads and reconnects to in-progress build jobs after refresh. Functional but not yet fully stabilized — always keep a working fallback kernel in your bootloader. - **Jobs** — view active jobs, reopen live output, browse persisted history with log viewing, delete, and purge actions (stored in SQLite at `/var/lib/arbor/history.db`), and surface recovered orphaned/unknown jobs after daemon restart ## Screenshots diff --git a/backend/arbor/action_security.py b/backend/arbor/action_security.py index 99e38e0..67819ff 100644 --- a/backend/arbor/action_security.py +++ b/backend/arbor/action_security.py @@ -40,6 +40,9 @@ "eclean_pretend", "snapshot_export", "disk_usage", + "kernel_status", + "kernel_available", + "limine_config_read", } _PRETEND_COMMANDS = { @@ -47,6 +50,10 @@ "emerge_uninstall_pretend", "emerge_depclean_pretend", "revdep_rebuild_pretend", + "kernel_install_pretend", + "kernel_oldconfig", + "kernel_switch_src", + "kernel_copy_config", } _APPROVAL_REQUIRED_COMMANDS = { @@ -63,6 +70,21 @@ "eclean_run", "snapshot_import", "revdep_rebuild", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_olddefconfig", + "kernel_build", + "kernel_modules_install", + "kernel_make_install", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "limine_config_write", + "limine_config_auto_update", } _TARGET_KEYS = ("atom", "name", "cfg_file", "job_id") diff --git a/backend/arbor/authorization.py b/backend/arbor/authorization.py index 95ef992..108c9f2 100644 --- a/backend/arbor/authorization.py +++ b/backend/arbor/authorization.py @@ -75,6 +75,28 @@ class StepUpRequiredError(PermissionError): "revdep_rebuild_pretend", "revdep_rebuild", "disk_usage", + "kernel_status", + "kernel_available", + "kernel_install_pretend", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_oldconfig", + "kernel_olddefconfig", + "kernel_build", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "kernel_switch_src", + "kernel_copy_config", + "kernel_modules_install", + "kernel_make_install", + "limine_config_read", + "limine_config_write", + "limine_config_auto_update", } _ROLE_ALLOWED_CLASSES = { diff --git a/backend/arbor/main.py b/backend/arbor/main.py index 9c249c6..e06fc73 100644 --- a/backend/arbor/main.py +++ b/backend/arbor/main.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Annotated -from fastapi import Depends, FastAPI, File, Request, UploadFile, WebSocket, WebSocketDisconnect, Query +from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -1302,6 +1302,372 @@ async def snapshot_import(auth: Auth, request: Request, file: UploadFile = File( pass +# --------------------------------------------------------------------------- +# Kernel status / available / kernel.org releases +# --------------------------------------------------------------------------- + +_kernel_org_cache: dict = {"data": None, "ts": 0.0} +_KERNEL_ORG_CACHE_TTL = 3600.0 +_KERNEL_ORG_URL = "https://www.kernel.org/releases.json" + + +@app.get("/api/kernel/status") +async def kernel_status(auth: Auth): + return await query_one("kernel_status") + + +@app.get("/api/kernel/available") +async def kernel_available(auth: Auth): + results = await query_all("kernel_available") + return [r for r in results if "cpv" in r] + + +@app.get("/api/kernel/org-releases") +async def kernel_org_releases(auth: Auth): + import time as _time + import httpx + global _kernel_org_cache + now = _time.time() + if _kernel_org_cache["data"] is not None and now - _kernel_org_cache["ts"] < _KERNEL_ORG_CACHE_TTL: + return _kernel_org_cache["data"] + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(_KERNEL_ORG_URL) + resp.raise_for_status() + data = resp.json() + _kernel_org_cache = {"data": data, "ts": now} + return data + except Exception as exc: + log.warning("kernel.org fetch failed: %s", exc) + if _kernel_org_cache["data"] is not None: + return _kernel_org_cache["data"] + return JSONResponse(status_code=502, content={"error": f"Failed to fetch kernel.org releases: {exc}"}) + + +async def _ws_kernel_mutating(websocket: WebSocket, daemon_cmd: str, args: dict): + """Stream a mutating kernel daemon command directly (no background job).""" + if not await _ws_require_auth(websocket): + return + if not await _ws_step_up_if_mutating(websocket, daemon_cmd, args): + return + try: + daemon_args = {**args, "request_principal": _websocket_principal_binding(websocket)} + async for chunk in query(daemon_cmd, daemon_args): + await websocket.send_text(json.dumps(chunk)) + if chunk.get("done") or chunk.get("error"): + break + except WebSocketDisconnect: + pass + except Exception as e: + try: + await websocket.send_text(json.dumps({"error": str(e), "done": True})) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +@app.websocket("/ws/kernel/install-pretend") +async def ws_kernel_install_pretend(websocket: WebSocket, atom: str = Query(default="")): + await _ws_emerge(websocket, "kernel_install_pretend", atom) + + +@app.websocket("/ws/kernel/install") +async def ws_kernel_install( + websocket: WebSocket, + atom: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_install", + { + "atom": atom, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/bootloader-update") +async def ws_kernel_bootloader_update( + websocket: WebSocket, + bootloader_name: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_bootloader_update", + { + "bootloader_name": bootloader_name, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/boot-clean") +async def ws_kernel_boot_clean( + websocket: WebSocket, + versions: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + ver_list = [v.strip() for v in versions.split(",") if v.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_boot_clean", + { + "versions": ver_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/src-clean") +async def ws_kernel_src_clean( + websocket: WebSocket, + names: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + name_list = [n.strip() for n in names.split(",") if n.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_src_clean", + { + "names": name_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/modules-clean") +async def ws_kernel_modules_clean( + websocket: WebSocket, + versions: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + ver_list = [v.strip() for v in versions.split(",") if v.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_modules_clean", + { + "versions": ver_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/oldconfig") +async def ws_kernel_oldconfig(websocket: WebSocket): + await _ws_kernel_mutating(websocket, "kernel_oldconfig", {}) + + +@app.websocket("/ws/kernel/olddefconfig") +async def ws_kernel_olddefconfig( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_olddefconfig", + {"approval_request_id": approval_request_id, "approval_token": approval_token}, + ) + + +@app.websocket("/ws/kernel/build") +async def ws_kernel_build( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_build", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/initramfs") +async def ws_kernel_initramfs( + websocket: WebSocket, + kver: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_initramfs", + { + "kver": kver, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/module-rebuild") +async def ws_kernel_module_rebuild( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_module_rebuild", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/download-tarball") +async def ws_kernel_download_tarball( + websocket: WebSocket, + url: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_download_tarball", + { + "url": url, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/modules-install") +async def ws_kernel_modules_install( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_modules_install", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/make-install") +async def ws_kernel_make_install( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_make_install", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/switch-src") +async def ws_kernel_switch_src(websocket: WebSocket, dirname: str = Query(default="")): + await _ws_kernel_mutating(websocket, "kernel_switch_src", {"dirname": dirname}) + + +@app.websocket("/ws/kernel/copy-config") +async def ws_kernel_copy_config(websocket: WebSocket, src_dirname: str = Query(default="")): + await _ws_kernel_mutating(websocket, "kernel_copy_config", {"src_dirname": src_dirname}) + + +@app.websocket("/ws/kernel/reboot") +async def ws_kernel_reboot( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_reboot", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +# --------------------------------------------------------------------------- +# Limine config +# --------------------------------------------------------------------------- + +@app.get("/api/limine/config") +async def api_limine_config_get(auth: Auth): + result = await query_one("limine_config_read", {}) + if result.get("error"): + raise HTTPException(status_code=500, detail=result["error"]) + return result + + +@app.post("/api/limine/config") +async def api_limine_config_post(auth: Auth, request: Request): + require_min_role("owner") + require_recent_step_up_unless_cli_mode() + body = await _json_object_body(request) + if isinstance(body, JSONResponse): + return body + config_json = body.get("config_json", "") + approval_request_id = str(body.get("approval_request_id", "")).strip() + approval_token = str(body.get("approval_token", "")).strip() + result = await query_one( + "limine_config_write", + { + "config_json": config_json, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + "request_principal": _request_principal_binding(request), + }, + ) + return result + + +@app.post("/api/limine/auto-update") +async def api_limine_auto_update(auth: Auth, request: Request): + require_min_role("owner") + require_recent_step_up_unless_cli_mode() + body = await _json_object_body(request) + if isinstance(body, JSONResponse): + return body + kver = str(body.get("kver", "")).strip() + approval_request_id = str(body.get("approval_request_id", "")).strip() + approval_token = str(body.get("approval_token", "")).strip() + result = await query_one( + "limine_config_auto_update", + { + "kver": kver, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + "request_principal": _request_principal_binding(request), + }, + ) + return result + + # --------------------------------------------------------------------------- # Serve frontend static files # --------------------------------------------------------------------------- diff --git a/backend/daemon/main.py b/backend/daemon/main.py index 53f34ac..00f09cc 100644 --- a/backend/daemon/main.py +++ b/backend/daemon/main.py @@ -114,6 +114,28 @@ "revdep_rebuild_pretend", "revdep_rebuild", "disk_usage", + "kernel_status", + "kernel_available", + "kernel_install_pretend", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_oldconfig", + "kernel_olddefconfig", + "kernel_build", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "kernel_switch_src", + "kernel_copy_config", + "kernel_modules_install", + "kernel_make_install", + "limine_config_read", + "limine_config_write", + "limine_config_auto_update", } # --------------------------------------------------------------------------- @@ -2617,6 +2639,7 @@ async def _start_background_job( action_cmd: str = "", action_args: dict | None = None, stderr=asyncio.subprocess.STDOUT, + cwd: str | None = None, ): meta = action_metadata(action_cmd, action_args or {}) if action_cmd else {} async with _get_jobs_lock(): @@ -2630,6 +2653,7 @@ async def _start_background_job( stdout=asyncio.subprocess.PIPE, stderr=stderr, env=_EMERGE_ENV, + **({"cwd": cwd} if cwd is not None else {}), ) job_id = str(uuid.uuid4()) _jobs[job_id] = _Job( @@ -2811,6 +2835,1251 @@ async def cmd_disk_usage(_args): yield await in_thread(_disk_usage) +# --------------------------------------------------------------------------- +# Kernel status / information +# --------------------------------------------------------------------------- + +def _kernel_status() -> dict: + import re, subprocess, os + from pathlib import Path + _maybe_reload_portage() + import portage + + try: + running = subprocess.check_output(["uname", "-r"], text=True, timeout=5).strip() + except Exception: + running = "unknown" + + src_link = Path("/usr/src/linux") + src_target = "" + try: + if src_link.is_symlink(): + src_target = os.readlink(str(src_link)) + except OSError: + pass + + vdb = portage.db[portage.root]["vartree"].dbapi + kernel_pkgs = [] + for cpv in sorted(vdb.cpv_all()): + if not cpv.startswith("sys-kernel/"): + continue + _cat, pf = cpv.split("/", 1) + slot, build_time = vdb.aux_get(cpv, ["SLOT", "BUILD_TIME"]) + kernel_pkgs.append({"cpv": cpv, "pf": pf, "slot": slot, "build_time": build_time}) + + installkernel = bool(vdb.match("sys-kernel/installkernel")) + + boot_dir = Path("/boot") + boot_size = 0 + kernels = [] + if boot_dir.exists(): + try: + out = subprocess.check_output( + ["du", "-sb", "/boot"], stderr=subprocess.DEVNULL, timeout=10, text=True + ) + boot_size = int(out.split()[0]) + except Exception: + pass + + vmlinuz_re = re.compile(r"^vmlinuz-(.+?)(?:\.old)?$") + config_re = re.compile(r"^config-(.+?)(?:\.old)?$") + initrd_re = re.compile(r"^initramfs-(.+?)\.img(?:\.old)?$") + sysmap_re = re.compile(r"^System\.map-(.+?)(?:\.old)?$") + versions: dict = {} + all_files = sorted(boot_dir.iterdir()) + + # Pass 1: build versions dict from vmlinuz files only. + # Must be separate because config/initramfs sort before vmlinuz (c,i < v) + # so a single pass would miss associated files for not-yet-seen versions. + for f in all_files: + m = vmlinuz_re.match(f.name) + if not m: + continue + ver = m.group(1) + is_old = f.name.endswith(".old") + try: + sz = f.stat().st_size + except OSError: + sz = 0 + if ver not in versions: + versions[ver] = { + "version": ver, "running": ver == running, + "has_vmlinuz": False, "has_config": False, + "has_initramfs": False, "has_system_map": False, + "old_count": 0, "size": 0, + } + versions[ver]["size"] += sz + if is_old: + versions[ver]["old_count"] += 1 + else: + versions[ver]["has_vmlinuz"] = True + + # Pass 2: associate config, initramfs, System.map with known versions. + for f in all_files: + name = f.name + try: + sz = f.stat().st_size + except OSError: + sz = 0 + for pattern, field in [ + (config_re, "has_config"), + (initrd_re, "has_initramfs"), + (sysmap_re, "has_system_map"), + ]: + m = pattern.match(name) + if m: + ver = m.group(1) + if ver in versions: + versions[ver]["size"] += sz + if name.endswith(".old"): + versions[ver]["old_count"] += 1 + else: + versions[ver][field] = True + break + + kernels = sorted(versions.values(), key=lambda x: x["version"]) + + bootloaders = [] + for cfg_path, name, label in [ + ("/boot/grub/grub.cfg", "grub2", "GRUB2"), + ("/boot/grub2/grub.cfg", "grub2", "GRUB2"), + ("/boot/limine.conf", "limine", "Limine"), + ("/boot/loader/loader.conf", "systemd-boot", "systemd-boot"), + ("/efi/loader/loader.conf", "systemd-boot", "systemd-boot"), + ("/boot/syslinux/syslinux.cfg", "syslinux", "Syslinux"), + ("/boot/extlinux/extlinux.conf", "syslinux", "Extlinux"), + ]: + if Path(cfg_path).exists(): + if not any(b["name"] == name for b in bootloaders): + bootloaders.append({"name": name, "label": label, "cfg": cfg_path}) + + limine_disk = None + try: + with open("/proc/mounts") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[1] == "/boot": + dev = parts[0] + limine_disk = re.sub(r"p?\d+$", "", dev) + break + except OSError: + pass + + # Scan /usr/src for linux-* source directories + src_dirs = [] + try: + src_base = Path("/usr/src") + installed_pfs = {pkg["pf"] for pkg in kernel_pkgs} + for entry in sorted(src_base.iterdir()): + if not (entry.is_dir() and entry.name.startswith("linux-")): + continue + name = entry.name + kver = name[6:] # strip "linux-" + # Heuristic: portage kernels have suffix like -gentoo-rN + from_portage = name in installed_pfs or any( + p["pf"].endswith(kver) or kver in p["pf"] for p in kernel_pkgs + ) + # Derive real kver from source Makefile. + # The dir name may omit SUBLEVEL (e.g. linux-7.1-rc4 → 7.1.0-rc4). + real_kver = kver + try: + mk = entry / "Makefile" + if mk.is_file(): + _vp: dict = {} + with mk.open() as _mf: + for _i, _ml in enumerate(_mf): + if _i > 30: + break + _ml = _ml.strip() + if not _ml or _ml.startswith('#'): + continue + if '=' in _ml: + _lhs, _, _rhs = _ml.partition('=') + _lhs = _lhs.strip() + if _lhs in ("VERSION", "PATCHLEVEL", "SUBLEVEL", "EXTRAVERSION"): + _vp[_lhs] = _rhs.strip() + if "VERSION" in _vp and "PATCHLEVEL" in _vp: + _sl = _vp.get("SUBLEVEL", "0") or "0" + _ev = _vp.get("EXTRAVERSION", "") + real_kver = f"{_vp['VERSION']}.{_vp['PATCHLEVEL']}.{_sl}{_ev}" + except OSError: + pass + # For module/boot checks try both dirname-kver and Makefile-kver + _check_kvers = list(dict.fromkeys([real_kver, kver])) # dedup, real_kver first + # Check build artifacts + built = False + try: + arch_dir = entry / "arch" + if arch_dir.is_dir(): + for arch_sub in arch_dir.iterdir(): + boot_d = arch_sub / "boot" + if boot_d.is_dir(): + for img in ("bzImage", "zImage", "Image", "Image.gz"): + if (boot_d / img).is_file(): + built = True + break + if built: + break + if not built: + built = (entry / "vmlinux").is_file() + except OSError: + pass + modules_installed = any( + Path(f"/lib/modules/{k}/modules.dep").is_file() for k in _check_kvers + ) + kernel_in_boot = any( + Path(f"/boot/vmlinuz-{k}").is_file() for k in _check_kvers + ) + initramfs_in_boot = any( + Path(f"/boot/initramfs-{k}.img").is_file() for k in _check_kvers + ) + src_size = 0 + try: + out = subprocess.check_output( + ["du", "-sb", str(entry)], stderr=subprocess.DEVNULL, timeout=15, text=True + ) + src_size = int(out.split()[0]) + except Exception: + pass + src_dirs.append({ + "name": name, + "kver": kver, + "real_kver": real_kver, + "active": name == src_target or ("/" + name) == src_target, + "portage": from_portage, + "has_config": (entry / ".config").is_file(), + "built": built, + "modules_installed": modules_installed, + "kernel_in_boot": kernel_in_boot, + "initramfs_in_boot": initramfs_in_boot, + "size": src_size, + }) + except OSError: + pass + + # Scan /lib/modules for installed module directories + modules = [] + try: + mod_base = Path("/lib/modules") + boot_versions = {k["version"] for k in kernels} + for entry in sorted(mod_base.iterdir()): + if not entry.is_dir(): + continue + ver = entry.name + mod_size = 0 + try: + out = subprocess.check_output( + ["du", "-sb", str(entry)], stderr=subprocess.DEVNULL, timeout=10, text=True + ) + mod_size = int(out.split()[0]) + except Exception: + pass + modules.append({ + "version": ver, + "running": ver == running, + "in_boot": ver in boot_versions, + "size": mod_size, + }) + except OSError: + pass + + return { + "running": running, + "src_link": str(src_link), + "src_target": src_target, + "installkernel": installkernel, + "installed": kernel_pkgs, + "boot_size": boot_size, + "kernels": kernels, + "bootloaders": bootloaders, + "limine_disk": limine_disk, + "src_dirs": src_dirs, + "modules": modules, + } + + +def _kernel_available() -> list: + _maybe_reload_portage() + import portage + porttree = portage.db[portage.root]["porttree"].dbapi + vdb = portage.db[portage.root]["vartree"].dbapi + + UPGRADEABLE_CPS = [ + "sys-kernel/gentoo-sources", + "sys-kernel/gentoo-kernel", + "sys-kernel/gentoo-kernel-bin", + "sys-kernel/vanilla-sources", + "sys-kernel/hardened-sources", + ] + results = [] + for cp in UPGRADEABLE_CPS: + try: + versions = porttree.cp_list(cp) + except Exception: + continue + if not versions: + continue + installed_set = set(vdb.match(cp)) + for cpv in versions[-5:]: + _cat, pf = cpv.split("/", 1) + desc = "" + try: + desc = porttree.aux_get(cpv, ["DESCRIPTION"])[0] + except Exception: + pass + results.append({ + "cpv": cpv, "cp": cp, "pf": pf, + "installed": cpv in installed_set, + "description": desc, + }) + + # Sort newest-first across all package types using portage's version comparator. + try: + import functools + from portage.versions import vercmp, cpv_getkey as _cpv_getkey + + def _ver_of(cpv): + cp = _cpv_getkey(cpv) + return cpv[len(cp) + 1:] if cp else cpv + + results.sort( + key=lambda x: functools.cmp_to_key(vercmp)(_ver_of(x["cpv"])), + reverse=True, + ) + except Exception: + pass + + return results + + +async def cmd_kernel_status(_args): + yield await in_thread(_kernel_status) + + +async def cmd_kernel_available(_args): + for item in await in_thread(_kernel_available): + yield item + yield {"done": True} + + +async def cmd_kernel_install_pretend(args): + atom_raw = args.get("atom", "") + atom = _checked_atom(atom_raw) + if not atom or "sys-kernel/" not in atom: + yield {"error": "invalid kernel atom"} + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "emerge", "--pretend", "--verbose", "--color=n", atom, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=_EMERGE_ENV, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_install(args): + atom_raw = args.get("atom", "") + atom = _checked_atom(atom_raw) + if not atom or "sys-kernel/" not in atom: + yield {"error": "invalid kernel atom"} + return + approval_error = await in_thread( + _require_approval, + "kernel_install", + _approval_payload("kernel_install", args, {"atom": atom}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + atom, + ["emerge", "--verbose", "--color=n", atom], + kind="install", + action_cmd="kernel_install", + action_args={"atom": atom}, + ): + yield item + + +async def cmd_kernel_bootloader_update(args): + bootloader_name = str(args.get("bootloader_name", "")).strip() + if bootloader_name not in {"grub2", "systemd-boot"}: + yield {"error": "invalid bootloader_name; must be 'grub2' or 'systemd-boot'"} + return + approval_error = await in_thread( + _require_approval, + "kernel_bootloader_update", + _approval_payload("kernel_bootloader_update", args, {"bootloader_name": bootloader_name}), + ) + if approval_error: + yield approval_error + return + if bootloader_name == "grub2": + from pathlib import Path as _Path + if _Path("/boot/grub2/grub.cfg").exists(): + grub_cfg = "/boot/grub2/grub.cfg" + else: + grub_cfg = "/boot/grub/grub.cfg" + cmd = ["grub-mkconfig", "-o", grub_cfg] + else: + cmd = ["bootctl", "update"] + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=_EMERGE_ENV, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_boot_clean(args): + import re as _re + versions = args.get("versions", []) + if not isinstance(versions, list) or not versions: + yield {"error": "versions must be a non-empty list of strings"} + return + _ver_re = _re.compile(r'^[\w.+-]+$') + for v in versions: + if not isinstance(v, str) or not _ver_re.match(v): + yield {"error": f"invalid version string: {v!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_boot_clean", + _approval_payload("kernel_boot_clean", args, {"versions": versions}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + from pathlib import Path as _Path + boot = _Path("/boot") + lines = [] + for ver in versions: + candidates = [ + f"vmlinuz-{ver}", f"vmlinuz-{ver}.old", + f"initramfs-{ver}.img", f"initramfs-{ver}.img.old", + f"initrd-{ver}.img", f"initrd-{ver}.img.old", + f"config-{ver}", f"config-{ver}.old", + f"System.map-{ver}", f"System.map-{ver}.old", + ] + for name in candidates: + path = boot / name + if path.exists(): + path.unlink(missing_ok=True) + lines.append(f"Removed: {path}") + else: + lines.append(f"Skipped (not found): {path}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_modules_clean(args): + import re as _re + versions = args.get("versions", []) + if not isinstance(versions, list) or not versions: + yield {"error": "versions must be a non-empty list of strings"} + return + _ver_re = _re.compile(r'^[\w.+-]+$') + for v in versions: + if not isinstance(v, str) or not _ver_re.match(v): + yield {"error": f"invalid version string: {v!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_modules_clean", + _approval_payload("kernel_modules_clean", args, {"versions": versions}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + import shutil as _shutil + from pathlib import Path as _Path + mod_base = _Path("/lib/modules") + running = "" + try: + import subprocess as _sp + running = _sp.check_output(["uname", "-r"], text=True, timeout=5).strip() + except Exception: + pass + lines = [] + for ver in versions: + if ver == running: + lines.append(f"Skipped (running): /lib/modules/{ver}") + continue + path = mod_base / ver + if path.exists() and path.is_dir(): + try: + _shutil.rmtree(str(path)) + lines.append(f"Removed: /lib/modules/{ver}") + except Exception as e: + lines.append(f"Error removing /lib/modules/{ver}: {e}") + else: + lines.append(f"Skipped (not found): /lib/modules/{ver}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_src_clean(args): + import re as _re + names = args.get("names", []) + if not isinstance(names, list) or not names: + yield {"error": "names must be a non-empty list of strings"} + return + _name_re = _re.compile(r'^linux-[\w.+-]+$') + for n in names: + if not isinstance(n, str) or not _name_re.match(n): + yield {"error": f"invalid source name: {n!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_src_clean", + _approval_payload("kernel_src_clean", args, {"names": names}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + import shutil as _shutil, os as _os + from pathlib import Path as _Path + src_base = _Path("/usr/src") + # Resolve active symlink + active = "" + try: + lnk = src_base / "linux" + if lnk.is_symlink(): + target = _os.readlink(str(lnk)) + active = target.lstrip("/").split("/")[-1] if "/" in target else target + except OSError: + pass + lines = [] + for name in names: + if name == active: + lines.append(f"Skipped (active symlink): /usr/src/{name}") + continue + path = src_base / name + if path.exists() and path.is_dir(): + try: + _shutil.rmtree(str(path)) + lines.append(f"Removed: /usr/src/{name}") + except Exception as e: + lines.append(f"Error removing /usr/src/{name}: {e}") + else: + lines.append(f"Skipped (not found): /usr/src/{name}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_oldconfig(_args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "make", "listnewconfig", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=src_dir, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_olddefconfig(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + approval_error = await in_thread( + _require_approval, + "kernel_olddefconfig", + _approval_payload("kernel_olddefconfig", args, {}), + ) + if approval_error: + yield approval_error + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "make", "olddefconfig", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=src_dir, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_build(args): + import subprocess as _subprocess + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + nproc = _subprocess.check_output(["nproc"], text=True, timeout=5).strip() + approval_error = await in_thread( + _require_approval, + "kernel_build", + _approval_payload("kernel_build", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-build", + ["make", f"-j{nproc}"], + kind="kernel-build", + action_cmd="kernel_build", + action_args={}, + cwd=src_dir, + ): + yield item + + +async def cmd_kernel_modules_install(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + approval_error = await in_thread( + _require_approval, + "kernel_modules_install", + _approval_payload("kernel_modules_install", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-modules-install", + ["make", "modules_install"], + kind="kernel-modules-install", + action_cmd="kernel_modules_install", + action_args={}, + cwd=str(src), + ): + yield item + + +async def cmd_kernel_make_install(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + approval_error = await in_thread( + _require_approval, + "kernel_make_install", + _approval_payload("kernel_make_install", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-make-install", + ["make", "install"], + kind="kernel-make-install", + action_cmd="kernel_make_install", + action_args={}, + cwd=str(src), + ): + yield item + + +async def cmd_kernel_initramfs(args): + import re as _re + from pathlib import Path as _Path + kver = args.get("kver", "") + if not kver or not isinstance(kver, str): + yield {"error": "kver is required"} + return + if len(kver) > 64 or not _re.match(r'^[\w.+-]+$', kver): + yield {"error": f"invalid kver: {kver!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_initramfs", + _approval_payload("kernel_initramfs", args, {"kver": kver}), + ) + if approval_error: + yield approval_error + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "dracut", "--force", "--kver", kver, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + rc = proc.returncode + done_chunk: dict = {"done": True, "returncode": rc} + if rc == 0: + boot = _Path("/boot") + initramfs_found = ( + (boot / f"initramfs-{kver}.img").exists() + or (boot / f"initrd-{kver}.img").exists() + ) + done_chunk["initramfs_found"] = initramfs_found + yield done_chunk + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_module_rebuild(args): + approval_error = await in_thread( + _require_approval, + "kernel_module_rebuild", + _approval_payload("kernel_module_rebuild", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "@module-rebuild", + ["emerge", "--verbose", "--color=n", "@module-rebuild"], + kind="module-rebuild", + action_cmd="kernel_module_rebuild", + action_args={}, + ): + yield item + + +async def cmd_kernel_download_tarball(args): + import re as _re, threading as _threading, urllib.request as _urllib + + url = str(args.get("url", "")).strip() + _ALLOWED_HOSTS = ("https://cdn.kernel.org/", "https://www.kernel.org/") + if not any(url.startswith(h) for h in _ALLOWED_HOSTS): + yield {"error": "URL must be from cdn.kernel.org or www.kernel.org"} + return + if not (url.endswith(".tar.xz") or url.endswith(".tar.gz")): + yield {"error": "URL must point to a .tar.xz or .tar.gz tarball"} + return + + filename = url.split("/")[-1] + if filename.endswith(".tar.xz"): + dirname = filename[:-7] + else: + dirname = filename[:-7] + if not _re.match(r"^linux-[\w.+-]+$", dirname): + yield {"error": f"unexpected tarball name: {filename}"} + return + + from pathlib import Path as _Path + src_base = _Path("/usr/src") + dest_dir = src_base / dirname + tarball_path = src_base / filename + + if dest_dir.exists(): + yield {"error": f"{dest_dir} already exists — remove it first or pick a different version"} + return + + approval_error = await in_thread( + _require_approval, + "kernel_download_tarball", + _approval_payload("kernel_download_tarball", args, {"url": url}), + ) + if approval_error: + yield approval_error + return + + yield {"line": f"WARNING: GPG signature verification skipped"} + yield {"line": f"Downloading {filename} from kernel.org…"} + + # Get total size via HEAD + total = 0 + try: + req = _urllib.Request(url, method="HEAD") + with _urllib.urlopen(req, timeout=15) as r: + total = int(r.headers.get("Content-Length", 0)) + except Exception: + pass + if total: + yield {"line": f"File size: {total // (1024 * 1024)} MB"} + + # Download in background thread, poll file size for progress + done_evt = _threading.Event() + err_holder: list = [] + + def _dl(): + try: + _urllib.urlretrieve(url, str(tarball_path)) + except Exception as exc: + err_holder.append(str(exc)) + finally: + done_evt.set() + + t = _threading.Thread(target=_dl, daemon=True) + t.start() + + last_pct = -1 + while not done_evt.is_set(): + await asyncio.sleep(1) + try: + size = tarball_path.stat().st_size + if total: + pct = min(int(size * 100 / total), 99) + if pct != last_pct: + yield {"line": f" {pct}% ({size // (1024 * 1024)}/{total // (1024 * 1024)} MB)", "progress": size, "total": total} + last_pct = pct + else: + yield {"line": f" {size // (1024 * 1024)} MB downloaded…", "progress": size} + except OSError: + pass + + if err_holder: + try: + tarball_path.unlink(missing_ok=True) + except OSError: + pass + yield {"error": f"Download failed: {err_holder[0]}"} + return + + yield {"line": f"Download complete. Extracting to /usr/src/…"} + + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "tar", "-xf", str(tarball_path), "-C", str(src_base), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if line: + yield {"line": line} + await proc.wait() + if proc.returncode != 0: + yield {"error": f"tar extraction failed (rc={proc.returncode})"} + return + finally: + await _terminate_subprocess(proc) + + # Remove tarball + try: + tarball_path.unlink(missing_ok=True) + yield {"line": f"Tarball removed."} + except OSError as exc: + yield {"line": f"Warning: could not remove tarball: {exc}"} + + # Update /usr/src/linux symlink + linux_link = src_base / "linux" + try: + if linux_link.is_symlink() or linux_link.exists(): + linux_link.unlink() + linux_link.symlink_to(dirname) + yield {"line": f"/usr/src/linux → {dirname}"} + except OSError as exc: + yield {"line": f"Warning: could not update /usr/src/linux symlink: {exc}"} + + yield {"line": f"Done. Sources ready in /usr/src/{dirname}"} + yield {"done": True, "returncode": 0, "dirname": dirname} + + +async def cmd_kernel_reboot(args): + approval_error = await in_thread( + _require_approval, + "kernel_reboot", + _approval_payload("kernel_reboot", args, {}), + ) + if approval_error: + yield approval_error + return + + yield {"line": "Scheduling reboot…"} + + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "shutdown", "-r", "now", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if line: + yield {"line": line} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_switch_src(args): + import re as _re + dirname = str(args.get("dirname", "")).strip() + if not _re.match(r'^linux-[\w.+-]+$', dirname): + yield {"error": "invalid source directory name"} + return + from pathlib import Path as _Path + target = _Path("/usr/src") / dirname + if not target.is_dir(): + yield {"error": f"/usr/src/{dirname} is not a directory"} + return + + def _switch(): + link = _Path("/usr/src/linux") + if link.is_symlink() or link.exists(): + link.unlink() + link.symlink_to(dirname) + + await in_thread(_switch) + yield {"line": f"/usr/src/linux → {dirname}"} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_copy_config(args): + import re as _re, shutil as _shutil + src_dirname = str(args.get("src_dirname", "")).strip() + if not _re.match(r'^linux-[\w.+-]+$', src_dirname): + yield {"error": "invalid source directory name"} + return + from pathlib import Path as _Path + src_config = _Path("/usr/src") / src_dirname / ".config" + if not src_config.is_file(): + yield {"error": f"/usr/src/{src_dirname}/.config not found"} + return + dst_dir = _Path("/usr/src/linux").resolve() + if not dst_dir.is_dir(): + yield {"error": "/usr/src/linux does not resolve to a directory"} + return + dst_config = dst_dir / ".config" + + def _copy(): + _shutil.copy2(str(src_config), str(dst_config)) + + await in_thread(_copy) + yield {"line": f"Copied {src_config} → {dst_config}"} + yield {"done": True, "returncode": 0} + + +# --------------------------------------------------------------------------- +# Limine bootloader config +# --------------------------------------------------------------------------- + +def _parse_limine_conf(text: str) -> dict: + """Parse /boot/limine.conf into structured data.""" + import re as _re + lines = text.replace('\r\n', '\n').split('\n') + + # Split preamble (before first entry line starting with /) from body + preamble = [] + body = [] + in_body = False + for line in lines: + if not in_body and line.startswith('/'): + in_body = True + (body if in_body else preamble).append(line) + + global_raw = '\n'.join(preamble) + + def _extract_global(key): + m = _re.search(r'^' + _re.escape(key) + r':\s*(.*)$', global_raw, _re.MULTILINE) + return m.group(1).strip() if m else '' + + # Split body into entry blocks (new block starts at column-0 '/') + blocks = [] + cur = [] + for line in body: + if line.startswith('/') and cur: + while cur and not cur[-1].strip(): + cur.pop() + blocks.append(cur) + cur = [line] + else: + cur.append(line) + if cur: + while cur and not cur[-1].strip(): + cur.pop() + if cur: + blocks.append(cur) + + entries = [] + for block in blocks: + if not block: + continue + header = block[0] + group_name = header.lstrip('/+').strip() + sub_name = '' + keys = {} + extra = [] + has_sub = False + for line in block[1:]: + s = line.strip() + if not s: + continue + if line.lstrip().startswith('//'): + sub_name = line.lstrip().lstrip('/').strip() + has_sub = True + elif ':' in s and not s.startswith('#'): + k, _, v = s.partition(':') + k = k.strip(); v = v.strip() + if k in ('protocol', 'path', 'module_path', 'cmdline'): + keys[k] = v + else: + extra.append(line) + else: + extra.append(line) + if has_sub and keys.get('protocol') == 'linux': + entries.append({ + 'type': 'linux', + 'group_name': group_name, + 'sub_name': sub_name, + 'path': keys.get('path', ''), + 'module_path': keys.get('module_path', ''), + 'cmdline': keys.get('cmdline', ''), + }) + else: + entries.append({'type': 'raw', 'raw': '\n'.join(block)}) + + return { + 'global_raw': global_raw, + 'timeout': _extract_global('timeout'), + 'default_entry': _extract_global('default_entry'), + 'remember_last_entry': _extract_global('remember_last_entry'), + 'entries': entries, + } + + +def _serialize_limine_conf(data: dict) -> str: + """Serialize structured config back to limine.conf text.""" + import re as _re + global_raw = data.get('global_raw', '') + + def _replace_setting(text, key, value): + pat = _re.compile(r'^(' + _re.escape(key) + r':\s*)(.*)$', _re.MULTILINE) + if pat.search(text): + return pat.sub(r'\g<1>' + str(value), text) + return key + ': ' + str(value) + '\n' + text + + global_raw = _replace_setting(global_raw, 'timeout', data.get('timeout', '5')) + global_raw = _replace_setting(global_raw, 'default_entry', data.get('default_entry', '1')) + rl = data.get('remember_last_entry', '') + if rl: + global_raw = _replace_setting(global_raw, 'remember_last_entry', rl) + + if not global_raw.endswith('\n'): + global_raw += '\n' + + parts = [global_raw] + for entry in data.get('entries', []): + if entry.get('type') == 'linux': + b = f"/+{entry['group_name']}\n" + if entry.get('sub_name'): + b += f" //{entry['sub_name']}\n" + b += f" protocol: linux\n" + b += f" path: {entry.get('path', '')}\n" + if entry.get('module_path'): + b += f" module_path: {entry['module_path']}\n" + if entry.get('cmdline'): + b += f" cmdline: {entry['cmdline']}\n" + parts.append(b) + elif entry.get('type') == 'raw': + parts.append(entry.get('raw', '')) + + result = '' + for i, part in enumerate(parts): + if i == 0: + result = part + else: + if result and not result.endswith('\n\n'): + result = result.rstrip('\n') + '\n\n' + result += part + if not result.endswith('\n'): + result += '\n' + return result + + +async def cmd_limine_config_read(_args): + from pathlib import Path as _Path + conf = _Path("/boot/limine.conf") + if not conf.is_file(): + yield {"error": "/boot/limine.conf not found"} + return + try: + text = conf.read_text(errors="replace") + parsed = _parse_limine_conf(text) + yield parsed + except OSError as e: + yield {"error": str(e)} + + +async def cmd_limine_config_write(args): + import json as _json, shutil as _shutil, datetime as _dt + from pathlib import Path as _Path + + config_json = args.get("config_json", "") + if not config_json: + yield {"error": "config_json is required"} + return + try: + config_data = _json.loads(config_json) + except Exception as e: + yield {"error": f"invalid config_json: {e}"} + return + + approval_error = await in_thread( + _require_approval, + "limine_config_write", + _approval_payload("limine_config_write", args, {}), + ) + if approval_error: + yield approval_error + return + + conf_path = _Path("/boot/limine.conf") + + def _do_write(): + ts = _dt.datetime.now().strftime("%Y%m%d%H%M%S") + backup = conf_path.parent / f"limine.conf.bak.{ts}" + _shutil.copy2(str(conf_path), str(backup)) + new_text = _serialize_limine_conf(config_data) + conf_path.write_text(new_text) + return str(backup) + + backup = await in_thread(_do_write) + yield {"done": True, "returncode": 0, "backup": backup} + + +async def cmd_limine_config_auto_update(args): + """Clone the currently-running kernel's Limine entry and point it at kver.""" + import copy as _copy, os as _os, shutil as _shutil + import datetime as _dt + from pathlib import Path as _Path + + kver = str(args.get("kver", "")).strip() + if not kver: + yield {"error": "kver is required"} + return + + approval_error = await in_thread( + _require_approval, + "limine_config_auto_update", + _approval_payload("limine_config_auto_update", args, {"kver": kver}), + ) + if approval_error: + yield approval_error + return + + def _do(): + conf_path = _Path("/boot/limine.conf") + if not conf_path.is_file(): + return {"error": "limine.conf not found"} + + text = conf_path.read_text(errors="replace") + data = _parse_limine_conf(text) + + running_kver = _os.uname().release + linux_entries = [e for e in data["entries"] if e.get("type") == "linux"] + + if not linux_entries: + return {"error": "no linux entries found in limine.conf"} + + # Find the entry matching the running kernel; fall back to first linux entry + source_entry = None + for e in linux_entries: + if running_kver in e.get("path", ""): + source_entry = e + break + if source_entry is None: + source_entry = linux_entries[0] + + def _subst(s): + if running_kver and running_kver != kver and running_kver in s: + return s.replace(running_kver, kver) + return s + + new_entry = _copy.deepcopy(source_entry) + src_path = source_entry.get("path", "") + new_entry["path"] = _subst(src_path) if src_path else f"boot():/vmlinuz-{kver}" + src_mp = source_entry.get("module_path", "") + new_entry["module_path"] = _subst(src_mp) if src_mp else f"boot():/initramfs-{kver}.img" + new_entry["group_name"] = _subst(source_entry.get("group_name", f"Linux {kver}")) + + # Replace existing entry for kver in-place, or insert before first linux entry + target_idx = None + for i, e in enumerate(data["entries"]): + if e.get("type") == "linux" and kver in e.get("path", ""): + target_idx = i + break + + if target_idx is not None: + data["entries"][target_idx] = new_entry + new_entry_pos = target_idx + 1 # 1-based + else: + first_linux = next((i for i, e in enumerate(data["entries"]) if e.get("type") == "linux"), 0) + data["entries"].insert(first_linux, new_entry) + new_entry_pos = first_linux + 1 # 1-based + + # Only update default_entry; leave timeout and remember_last_entry + # untouched so the user can still pick a different kernel from the menu. + data["default_entry"] = str(new_entry_pos) + + ts = _dt.datetime.now().strftime("%Y%m%d%H%M%S") + backup = conf_path.parent / f"limine.conf.bak.{ts}" + _shutil.copy2(str(conf_path), str(backup)) + conf_path.write_text(_serialize_limine_conf(data)) + return { + "done": True, "returncode": 0, + "backup": str(backup), + "kver": kver, + "source_kver": running_kver, + "default_entry": new_entry_pos, + } + + result = await in_thread(_do) + yield result + + async def cmd_emerge_sync(_args): approval_error = await in_thread(_require_approval, "emerge_sync", _approval_payload("emerge_sync", _args)) if approval_error: @@ -3790,6 +5059,28 @@ async def cmd_snapshot_import(args): "revdep_rebuild_pretend": cmd_revdep_rebuild_pretend, "revdep_rebuild": cmd_revdep_rebuild, "disk_usage": cmd_disk_usage, + "kernel_status": cmd_kernel_status, + "kernel_available": cmd_kernel_available, + "kernel_install_pretend": cmd_kernel_install_pretend, + "kernel_install": cmd_kernel_install, + "kernel_bootloader_update": cmd_kernel_bootloader_update, + "kernel_boot_clean": cmd_kernel_boot_clean, + "kernel_modules_clean": cmd_kernel_modules_clean, + "kernel_src_clean": cmd_kernel_src_clean, + "kernel_oldconfig": cmd_kernel_oldconfig, + "kernel_olddefconfig": cmd_kernel_olddefconfig, + "kernel_build": cmd_kernel_build, + "kernel_modules_install": cmd_kernel_modules_install, + "kernel_make_install": cmd_kernel_make_install, + "kernel_initramfs": cmd_kernel_initramfs, + "kernel_module_rebuild": cmd_kernel_module_rebuild, + "kernel_download_tarball": cmd_kernel_download_tarball, + "kernel_reboot": cmd_kernel_reboot, + "kernel_switch_src": cmd_kernel_switch_src, + "kernel_copy_config": cmd_kernel_copy_config, + "limine_config_read": cmd_limine_config_read, + "limine_config_write": cmd_limine_config_write, + "limine_config_auto_update": cmd_limine_config_auto_update, } diff --git a/frontend/alpine/app.js b/frontend/alpine/app.js index 1c98de6..69af233 100644 --- a/frontend/alpine/app.js +++ b/frontend/alpine/app.js @@ -140,7 +140,10 @@ newsRead: (id) => _post('/news/read', { id }), newsReadAll: () => _post('/news/read-all', {}), glsa: () => _get('/glsa'), - diskUsage: () => _get('/storage-stats'), + diskUsage: () => _get('/storage-stats'), + kernelStatus: () => _get('/kernel/status'), + kernelAvailable: () => _get('/kernel/available'), + kernelOrgReleases:() => _get('/kernel/org-releases'), snapshotImport: (file) => { const fd = new FormData(); fd.append('file', file) return fetch('/api/snapshot/import', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken() }, body: fd }) @@ -278,6 +281,14 @@ ) } + function wsKernel(cmd, onMsg, extra = {}) { + return _openAuthedWebSocket( + _withQuery('/ws/kernel/' + cmd, extra), + onMsg, + { requiresStepUp: cmd !== 'install-pretend' }, + ) + } + function wsJobAttach(jobId, onMsg) { return _openAuthedWebSocket('/ws/jobs/' + encodeURIComponent(jobId), onMsg) } @@ -772,6 +783,7 @@ { id: 'updates', label: 'Maintenance' }, { id: 'overlays', label: 'Overlays' }, { id: 'snapshot', label: 'Snapshot' }, + { id: 'kernel', label: 'Kernel (beta)' }, { id: 'news', label: 'News' }, { id: 'glsa', label: 'Advisories' }, { id: 'jobs', label: 'Jobs' }, @@ -1039,6 +1051,7 @@ } } if (r.view === 'snapshot') return { section: 'Config', title: 'Snapshot', detail: 'Export or import /etc/portage configuration' } + if (r.view === 'kernel') return { section: 'System', title: 'Kernel', detail: 'Installed kernels, available updates, /boot inventory and bootloader info' } if (r.view === 'install') { return { section: 'Packages', @@ -4312,6 +4325,7 @@ ${labels} Alpine.data('newsComponent', newsComponent) Alpine.data('glsaComponent', glsaComponent) Alpine.data('snapshotComponent', snapshotComponent) + Alpine.data('kernelComponent', kernelComponent) }) // --------------------------------------------------------------------------- @@ -4617,6 +4631,921 @@ ${labels} } } + const _mkKernelOp = () => ({ lines: [], running: false, rc: null, ws: null, approvalRequest: null, approvalCommand: '', _approvalPollTimer: null }) + + function kernelComponent() { + return { + status: null, + available: [], + orgReleases: null, + loading: true, + error: null, + activeTab: 'overview', + open: { status: false, installed: false, boot: false, bootloaders: false, srcdirs: false, portage: false, upstream: false, limine: false }, + switchSrc: { op: _mkKernelOp(), dirname: null }, + pretend: _mkKernelOp(), + install: _mkKernelOp(), + selectedAtom: null, + bootloader: { op: _mkKernelOp(), name: null }, + cleanup: { selected: {}, op: _mkKernelOp() }, + modulesCleanup: { selected: {}, op: _mkKernelOp() }, + srcCleanup: { selected: {}, op: _mkKernelOp() }, + download: { op: _mkKernelOp(), version: null, progress: 0, total: 0 }, + manualFlow: { + step: 0, + kver: '', + oldconfig: _mkKernelOp(), + olddefconfig: _mkKernelOp(), + build: _mkKernelOp(), + modulesInstall: _mkKernelOp(), + makeInstall: _mkKernelOp(), + initramfs: Object.assign(_mkKernelOp(), { initramfs_found: null }), + moduleRebuild: _mkKernelOp(), + limineAutoUpdate: _mkKernelOp(), + }, + reboot: { op: _mkKernelOp(), confirmText: '' }, + copyConfig: { op: _mkKernelOp(), srcDirname: '' }, + limine: { + loading: false, + error: null, + config: null, + edited: null, + dirty: false, + saveOp: _mkKernelOp(), + showPreview: false, + newEntry: null, + }, + async init() { await this.load(); this._restoreWizardState(); await this._restoreKernelJobs(); if (this.limineHasLimine()) void this.limineLoad() }, + async load() { + this.loading = true; this.error = null + const [s, a, o] = await Promise.allSettled([ + api.kernelStatus(), + api.kernelAvailable(), + api.kernelOrgReleases(), + ]) + if (s.status === 'fulfilled' && s.value && !s.value.error) { + this.status = s.value + } else { + this.error = (s.value && s.value.error) || 'Failed to load kernel status' + } + if (a.status === 'fulfilled') this.available = Array.isArray(a.value) ? a.value : [] + if (o.status === 'fulfilled' && o.value && !o.value.error) this.orgReleases = o.value + this.loading = false + }, + _saveWizardState() { + if (!this.manualFlow.step) return + try { + _scopedStorageSet('kw_wizard', JSON.stringify({ + kver: this.manualFlow.kver, + build_rc: this.manualFlow.build.rc, + modules_install_rc: this.manualFlow.modulesInstall.rc, + make_install_rc: this.manualFlow.makeInstall.rc, + initramfs_rc: this.manualFlow.initramfs.rc, + module_rebuild_rc: this.manualFlow.moduleRebuild.rc, + })) + } catch(e) {} + }, + _restoreWizardState() { + try { + var raw = _scopedStorageGet('kw_wizard') + if (!raw) return + var saved = JSON.parse(raw) + if (!saved || !saved.kver) return + this.manualFlow.step = 1 + this.manualFlow.kver = saved.kver + if (saved.build_rc !== null && saved.build_rc !== undefined) this.manualFlow.build.rc = saved.build_rc + if (saved.modules_install_rc !== null && saved.modules_install_rc !== undefined) this.manualFlow.modulesInstall.rc = saved.modules_install_rc + if (saved.make_install_rc !== null && saved.make_install_rc !== undefined) this.manualFlow.makeInstall.rc = saved.make_install_rc + if (saved.initramfs_rc !== null && saved.initramfs_rc !== undefined) this.manualFlow.initramfs.rc = saved.initramfs_rc + if (saved.module_rebuild_rc !== null && saved.module_rebuild_rc !== undefined) this.manualFlow.moduleRebuild.rc = saved.module_rebuild_rc + } catch(e) {} + }, + _clearWizardState() { + try { _scopedStorageRemove('kw_wizard') } catch(e) {} + }, + async _restoreKernelJobs() { + let allJobs + try { allJobs = await jobs.list() } catch(e) { return } + if (!Array.isArray(allJobs)) return + const KERNEL_CMDS = { + kernel_build: true, kernel_modules_install: true, kernel_make_install: true, + kernel_module_rebuild: true, kernel_install: true, + } + const running = allJobs.filter(function(j) { return j.status === 'running' && KERNEL_CMDS[j.action_cmd] }) + if (!running.length) return + const self = this + running.forEach(function(job) { + if (job.action_cmd === 'kernel_build') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.build + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-build', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_modules_install') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.modulesInstall + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_make_install') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.makeInstall + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-make-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_module_rebuild') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.moduleRebuild + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_install') { + const op = self.install + if (op.running) return + const atom = job.atom || '' + self.selectedAtom = atom + self.open.portage = true + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLine(op, msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0 && self.isSourcesKernel(atom)) self.initManualFlow() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } + }) + }, + toggle(key) { this.open[key] = !this.open[key] }, + fmtSize(bytes) { return _fmtBytes(bytes) }, + kernelType(pf) { + if (!pf) return '' + if (pf.includes('kernel-bin')) return 'dist-bin' + if (pf.includes('gentoo-kernel')) return 'dist' + if (pf.includes('-sources')) return 'sources' + return 'other' + }, + orgReleaseDate(r) { + if (!r || !r.released) return '' + const rel = r.released + if (typeof rel === 'string') return rel + if (rel && typeof rel === 'object') { + if (rel.isoformat) return rel.isoformat + if (rel.date) return rel.date + if (typeof rel.timestamp === 'number') + return new Date(rel.timestamp * 1000).toISOString().slice(0, 10) + } + return '' + }, + _appendLineTo(op, elemId, line) { + op.lines.push(line) + if (op.lines.length > 2000) op.lines.splice(0, op.lines.length - 2000) + this.$nextTick(function() { const el = document.getElementById(elemId); if (el) el.scrollTop = el.scrollHeight }) + }, + _appendLine(op, line) { + this._appendLineTo(op, 'kw-term', line) + }, + isSourcesKernel(cpv) { + return cpv && cpv.includes('-sources/') + }, + kverFromSrcTarget(src_target) { + if (!src_target) return '' + return src_target.replace(/^linux-/, '') + }, + showManualFlow() { + return this.manualFlow.step > 0 || (this.install.rc === 0 && this.selectedAtom && this.isSourcesKernel(this.selectedAtom)) + }, + initManualFlow() { + const kver = this.kverFromSrcTarget(this.status && this.status.src_target) + this.manualFlow.kver = kver + this.manualFlow.step = 1 + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = _mkKernelOp() + this.manualFlow.modulesInstall = _mkKernelOp() + this.manualFlow.makeInstall = _mkKernelOp() + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + }, + srcDirs() { + return this.status && Array.isArray(this.status.src_dirs) ? this.status.src_dirs : [] + }, + configSources() { + return this.srcDirs().filter(function(s) { return !s.active && s.has_config }) + }, + startCopyConfig() { + const dirname = this.copyConfig.srcDirname + if (!dirname) return + const op = this.copyConfig.op + const self = this + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('copy-config', function(msg) { + if (msg.line !== undefined) op.lines.push(msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { src_dirname: dirname }) + }, + selectSource(src) { + this.manualFlow.kver = src.real_kver || src.kver + this.manualFlow.step = 1 + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = Object.assign(_mkKernelOp(), { rc: src.built ? 0 : null }) + this.manualFlow.modulesInstall = Object.assign(_mkKernelOp(), { rc: src.modules_installed ? 0 : null }) + this.manualFlow.makeInstall = Object.assign(_mkKernelOp(), { rc: src.kernel_in_boot ? 0 : null }) + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null, rc: src.initramfs_in_boot ? 0 : null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + this.activeTab = 'build' + }, + resetManualFlow() { + this.manualFlow.step = 0 + this.manualFlow.kver = '' + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = _mkKernelOp() + this.manualFlow.modulesInstall = _mkKernelOp() + this.manualFlow.makeInstall = _mkKernelOp() + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + }, + startSwitchSrc(dirname) { + const op = this.switchSrc.op + const self = this + this.switchSrc.dirname = dirname + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('switch-src', function(msg) { + if (msg.line !== undefined) op.lines.push(msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + self.load() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { dirname: dirname }) + }, + selectAtom(cpv) { + if (this.selectedAtom === cpv) return + this.selectedAtom = cpv + this.pretend = _mkKernelOp() + this.install = _mkKernelOp() + }, + startKernelPretend(cpv) { + this.selectAtom(cpv) + this.open.portage = true + const op = this.pretend + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('install-pretend', (msg) => { + if (msg.line !== undefined) this._appendLine(op, msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode ?? null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { atom: cpv }) + }, + async startKernelInstall(cpv) { + this.selectAtom(cpv) + this.open.portage = true + const op = this.install + op.lines = []; op.rc = null; op.running = true + const self = this + const doInstall = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLine(op, msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0 && self.isSourcesKernel(cpv)) self.initManualFlow() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { atom: cpv, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_install', { atom: cpv }, + function(lines) { op.lines = lines }, + function() { doInstall(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doInstall(approval.request_id) + }, + async startBootloaderUpdate(name) { + const op = this.bootloader.op + this.bootloader.name = name + op.lines = []; op.rc = null; op.running = true + const self = this + const doUpdate = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('bootloader-update', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-bl-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { bootloader_name: name, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_bootloader_update', { bootloader_name: name }, + function(lines) { op.lines = lines }, + function() { doUpdate(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doUpdate(approval.request_id) + }, + toggleCleanup(ver) { + this.cleanup.selected[ver] = !this.cleanup.selected[ver] + }, + async startBootClean() { + const vers = Object.keys(this.cleanup.selected).filter(function(v) { return this.cleanup.selected[v] }, this) + if (!vers.length) return + const op = this.cleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('boot-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { versions: vers.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_boot_clean', { versions: vers.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + toggleSrcCleanup(name) { + this.srcCleanup.selected[name] = !this.srcCleanup.selected[name] + }, + async startSrcClean() { + const names = Object.keys(this.srcCleanup.selected).filter(function(n) { return this.srcCleanup.selected[n] }, this) + if (!names.length) return + const op = this.srcCleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('src-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-src-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { names: names.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_src_clean', { names: names.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + toggleModulesCleanup(ver) { + this.modulesCleanup.selected[ver] = !this.modulesCleanup.selected[ver] + }, + async startModulesClean() { + const vers = Object.keys(this.modulesCleanup.selected).filter(function(v) { return this.modulesCleanup.selected[v] }, this) + if (!vers.length) return + const op = this.modulesCleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('modules-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-modules-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { versions: vers.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_modules_clean', { versions: vers.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + async startKernelDownload(url, version) { + const op = this.download.op + const self = this + this.download.version = version + this.download.progress = 0 + this.download.total = 0 + op.lines = []; op.rc = null; op.running = true + this.open.upstream = true + const doDownload = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('download-tarball', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-dl-term', msg.line) + if (msg.progress !== undefined) self.download.progress = msg.progress + if (msg.total !== undefined) self.download.total = msg.total + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0) { + self.load() + self.manualFlow.kver = version + self.manualFlow.step = 1 + } + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { url: url, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_download_tarball', { url: url }, + function(lines) { op.lines = lines }, + function() { doDownload(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doDownload(approval.request_id) + }, + startOldconfig() { + const op = this.manualFlow.oldconfig + const self = this + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('oldconfig', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-oldconfig', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + }, + async startOlddefconfig() { + const op = this.manualFlow.olddefconfig + const self = this + op.lines = []; op.rc = null; op.running = true + const doOlddefconfig = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('olddefconfig', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-olddefconfig', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_olddefconfig', {}, + function(lines) { op.lines = lines }, + function() { doOlddefconfig(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doOlddefconfig(approval.request_id) + }, + async startBuild() { + const op = this.manualFlow.build + op.lines = []; op.rc = null; op.running = true + const self = this + const doBuild = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('build', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-build', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_build', {}, + function(lines) { op.lines = lines }, + function() { doBuild(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doBuild(approval.request_id) + }, + async startModulesInstall() { + const op = this.manualFlow.modulesInstall + op.lines = []; op.rc = null; op.running = true + const self = this + const doIt = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('modules-install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_modules_install', {}, + function(lines) { op.lines = lines }, + function() { doIt(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doIt(approval.request_id) + }, + async startMakeInstall() { + const op = this.manualFlow.makeInstall + op.lines = []; op.rc = null; op.running = true + const self = this + const doIt = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('make-install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-make-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_make_install', {}, + function(lines) { op.lines = lines }, + function() { doIt(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doIt(approval.request_id) + }, + async startInitramfs() { + const kver = this.manualFlow.kver + const op = this.manualFlow.initramfs + op.lines = []; op.rc = null; op.running = true; op.initramfs_found = null + const self = this + const doInitramfs = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('initramfs', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-initramfs', msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.initramfs_found !== undefined) op.initramfs_found = msg.initramfs_found + self._saveWizardState() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { kver: kver, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_initramfs', { kver: kver }, + function(lines) { op.lines = lines }, + function() { doInitramfs(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doInitramfs(approval.request_id) + }, + async startModuleRebuild() { + const op = this.manualFlow.moduleRebuild + op.lines = []; op.rc = null; op.running = true + const self = this + const doRebuild = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('module-rebuild', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_module_rebuild', {}, + function(lines) { op.lines = lines }, + function() { doRebuild(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doRebuild(approval.request_id) + }, + async startReboot() { + if (this.reboot.confirmText !== 'REBOOT') return + const op = this.reboot.op + const self = this + op.lines = []; op.rc = null; op.running = true + const doReboot = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('reboot', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-reboot-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_reboot', {}, + function(lines) { op.lines = lines }, + function() { doReboot(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doReboot(approval.request_id) + }, + limineHasLimine() { + if (!this.status || !Array.isArray(this.status.bootloaders)) return false + return this.status.bootloaders.some(function(b) { return b.name === 'limine' }) + }, + async limineLoad() { + if (!this.limineHasLimine()) return + this.limine.loading = true; this.limine.error = null + try { + const data = await _get('/limine/config') + this.limine.config = data + this.limine.edited = JSON.parse(JSON.stringify(data)) + this.limine.dirty = false + } catch(e) { + this.limine.error = e.message || 'Failed to load limine.conf' + } + this.limine.loading = false + }, + limineDefaultSelSync() { + var self = this + this.$nextTick(function() { + var sel = self.$refs.limineDefaultSel + if (!sel || !self.limine.edited) return + var idx = parseInt(self.limine.edited.default_entry, 10) - 1 + if (!isNaN(idx) && idx >= 0 && idx < sel.options.length) sel.selectedIndex = idx + }) + }, + limineDefaultEntryChanged(selectedIndex) { + this.limine.edited.default_entry = String(selectedIndex + 1) + this.limine.dirty = true + }, + limineEntries() { + if (!this.limine.edited) return [] + return this.limine.edited.entries || [] + }, + limineLinuxEntries() { + return this.limineEntries().filter(function(e) { return e.type === 'linux' }) + }, + limineMoveUp(idx) { + const entries = this.limine.edited.entries + if (idx < 1) return + const tmp = entries[idx - 1]; entries[idx - 1] = entries[idx]; entries[idx] = tmp + this.limine.dirty = true + this._limineFixDefaultEntry(idx, idx - 1) + }, + limineMoveDown(idx) { + const entries = this.limine.edited.entries + if (idx >= entries.length - 1) return + const tmp = entries[idx + 1]; entries[idx + 1] = entries[idx]; entries[idx] = tmp + this.limine.dirty = true + this._limineFixDefaultEntry(idx, idx + 1) + }, + _limineFixDefaultEntry(fromIdx, toIdx) { + const de = parseInt(this.limine.edited.default_entry, 10) + if (isNaN(de)) return + const from1 = fromIdx + 1; const to1 = toIdx + 1 + if (de === from1) this.limine.edited.default_entry = String(to1) + else if (de === to1) this.limine.edited.default_entry = String(from1) + }, + limineRemoveEntry(idx) { + this.limine.edited.entries.splice(idx, 1) + this.limine.dirty = true + }, + limineAddEntry() { + const entries = this.limine.edited.entries + // Insert before raw entries (EFI etc) — find last linux entry position + let insertAt = 0 + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === 'linux') { insertAt = i + 1; break } + } + const newEntry = { + type: 'linux', + group_name: 'New Entry', + sub_name: 'New Entry', + path: 'boot():/vmlinuz-', + module_path: 'boot():/initramfs-.img', + cmdline: '', + } + entries.splice(insertAt, 0, newEntry) + this.limine.dirty = true + }, + liminePreviewText() { + if (!this.limine.edited) return '' + // Build preview manually (no access to Python serializer) + const d = this.limine.edited + let out = d.global_raw || '' + if (!out.endsWith('\n')) out += '\n' + const entries = d.entries || [] + for (let i = 0; i < entries.length; i++) { + const e = entries[i] + if (!out.endsWith('\n\n')) out = out.replace(/\n*$/, '\n\n') + if (e.type === 'linux') { + out += '/+' + e.group_name + '\n' + if (e.sub_name) out += ' //' + e.sub_name + '\n' + out += ' protocol: linux\n' + out += ' path: ' + e.path + '\n' + if (e.module_path) out += ' module_path: ' + e.module_path + '\n' + if (e.cmdline) out += ' cmdline: ' + e.cmdline + '\n' + } else if (e.type === 'raw') { + out += e.raw || '' + } + } + if (!out.endsWith('\n')) out += '\n' + return out + }, + async limineSave() { + if (!this.limine.edited) return + const op = this.limine.saveOp + const self = this + op.lines = []; op.rc = null; op.running = true + const configJson = JSON.stringify(this.limine.edited) + const doSave = function(approvalId) { + op.running = true + _post('/limine/config', { + config_json: configJson, + approval_request_id: approvalId || '', + approval_token: '', + }).then(function(result) { + op.running = false + op.rc = result.returncode != null ? result.returncode : (result.error ? 1 : 0) + if (result.error) { + op.lines = ['Error: ' + result.error] + } else { + op.lines = ['Saved. Backup: ' + (result.backup || '')] + self.limine.dirty = false + self.limine.showPreview = false + self.limineLoad() + } + }).catch(function(e) { + op.running = false + op.rc = 1 + op.lines = ['Error: ' + e.message] + }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'limine_config_write', {}, + function(lines) { op.lines = lines }, + function() { doSave(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doSave(approval.request_id) + }, + + async startLimineAutoUpdate() { + const kver = this.manualFlow.kver + if (!kver) return + const op = this.manualFlow.limineAutoUpdate + const self = this + op.lines = []; op.rc = null; op.running = true + const doUpdate = function(approvalId) { + op.running = true + _post('/limine/auto-update', { + kver: kver, + approval_request_id: approvalId || '', + approval_token: '', + }).then(function(result) { + op.running = false + op.rc = result.returncode != null ? result.returncode : (result.error ? 1 : 0) + if (result.error) { + op.lines = ['Error: ' + result.error] + } else { + op.lines = [ + 'Entry added for ' + result.kver + ' (entry #' + result.default_entry + ').', + 'Select it from the Limine menu on next boot.', + 'Backup: ' + (result.backup || ''), + ] + self._saveWizardState() + if (self.limineHasLimine()) void self.limineLoad() + } + }).catch(function(e) { + op.running = false + op.rc = 1 + op.lines = ['Error: ' + e.message] + }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'limine_config_auto_update', { kver: kver }, + function(lines) { op.lines = lines }, + function() { doUpdate(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doUpdate(approval.request_id) + }, + } + } + function snapshotComponent() { return { exportLoading: false, @@ -4659,7 +5588,7 @@ ${labels} Object.assign(window, { navigate, navigateTo, navigateToUse, navigateBack, api, emerge, jobs, jobHistory, overlays, - wsEmerge, wsGlobalEmerge, wsJobAttach, wsOverlaySync, detachWs, + wsEmerge, wsGlobalEmerge, wsKernel, wsJobAttach, wsOverlaySync, detachWs, }) }()) diff --git a/frontend/alpine/index.html b/frontend/alpine/index.html index 9df0dcc..6a47ccd 100644 --- a/frontend/alpine/index.html +++ b/frontend/alpine/index.html @@ -1659,6 +1659,772 @@

Import

+ +
+
+ Beta feature. The kernel management wizard is functional but not yet fully stabilized — use with care and always keep a working fallback kernel in your bootloader. +
+
Loading kernel info…
+

+ + +
+ + + + +
+ + +
+
+
+

Running kernel

+ + + + +
Version
/usr/src/linux
installkernel
+ +

Installed sys-kernel packages

+
No sys-kernel packages found.
+ + + + + +
CPVType
+
+ +
+

/boot

+
No kernel files found in /boot.
+
+ + + + + +
VersionvmlinuzinitramfsconfigSize
+
+
+ + +
+
+

Waiting for approval. Run this command in a terminal on the host machine:

+

+              
+
+ +
+
+
+ + +
+

/lib/modules

+
No module directories found.
+
+ + + + + +
Version/bootSize
+
+
+ + +
+
+

Waiting for approval. Run this command in a terminal on the host machine:

+

+            
+
+ +
+
+
+ + +
+ + +

Sources in /usr/src

+
No kernel source directories found.
+
+ + + + + +
DirectoryTypeSymlinkStatusSize
+
+
+ + +
+
+

Waiting for approval. Run this command in a terminal on the host machine:

+

+          
+
+ +
+
+
+ switching… + + +
+
+ + +
+
+

Available versions (Portage)

+
No upgradeable kernel packages found.
+
+ + + + + +
PackageTypeStatus
+
+
+
+ + Pretend: + + + + + Install: + + + +
+
+

Waiting for approval. Run this command in a terminal on the host machine:

+

+                
+
+ +
+
+
+ +
+

kernel.org upstream

+
Unavailable — check internet connectivity.
+

Vanilla kernel — sources only, not tracked by Portage.

+
+ + + + + +
VersionTypeReleased
+
+
+
+ + + + ✓ ready + +
+
+

Waiting for approval:

+

+                
+
+ +
+
+
+
+
+ + +
+
+
+

Bootloaders

+
No bootloaders detected.
+
+ +
+

Waiting for approval. Run this command in a terminal on the host machine:

+

+                
+
+ +
+
+
+ +
+

+ Limine config + unsaved changes + +

+
Loading…
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+

+                
+
+ + A timestamped backup will be created in /boot before saving. +
+
+

Waiting for approval:

+

+                
+
+ +
+
✓ saved
+
+
+
+
+
+ + +
+
+

No source selected. Go to and click Build → on a kernel source.

+
+
+
+ Build wizard + + +
+
+ + +
+
+ 1 + Kernel config + Copy from existing source or use current .config +
+
+ +
+ + + + copying… + ✓ copied + ✗ failed +
+
+ + running… + + +
+ + running… + ✓ applied + +
+

Waiting for approval:

+

+                  
+

For advanced configuration, open a terminal and run make menuconfig in /usr/src/linux

+
+ +
+
+ +
+
+
+ + +
+
+ 2 + Build kernel (make -jN) + This can take 30–90 minutes +
+
+ + building… + ✓ done + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ + +
+
+ 3 + Install modules (make modules_install) +
+
+ + running… + ✓ done + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ + +
+
+ 4 + Install kernel image (make install) +
+
+ + running… + ✓ done + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ + +
+
+ 5 + Generate initramfs (dracut) +
+
+
+ + +
+ + running… + ✓ initramfs created + ✗ dracut succeeded but initramfs not found in /boot + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ + +
+
+ 6 + Rebuild external modules (@module-rebuild) + Skip if no external kernel modules are installed +
+
+ + running… + ✓ done + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ + +
+
+ 7 + Update Limine bootloader config + Copies the running kernel entry and points it at the new kernel +
+
+ + running… + ✓ done + +
+

Waiting for approval:

+

+                  
+
+ +
+
+
+ +
+
+
+ + +
+
Reboot
+

+ This will immediately reboot the system. The browser will lose connection. +

+
+ + + +
+
+
+ Rebooting… + ✓ reboot scheduled + +
+
+

Waiting for approval:

+

+            
+
+ +
+
+
+ +
+ diff --git a/frontend/alpine/style.css b/frontend/alpine/style.css index 9692f18..dc8e797 100644 --- a/frontend/alpine/style.css +++ b/frontend/alpine/style.css @@ -4711,6 +4711,133 @@ input:focus, .sn-result-ok { font-size: 0.82rem; color: #88a784; font-weight: 600; } .sn-result-backup { font-size: 0.78rem; color: #6e7681; font-family: monospace; } +/* ── Kernel wizard panel ─────────────────────────────────────────────────── */ +.kw-view { max-width: 1100px; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem 0; } +.kw-beta-notice { background: rgb(187 128 9 / 0.1); border: 1px solid rgb(187 128 9 / 0.35); border-radius: 6px; padding: 0.6rem 0.9rem; font-size: 0.82rem; color: #c9a227; margin-bottom: 0.25rem; } +.kw-loading { font-size: 0.85rem; color: #8b949e; padding: 1.5rem 0; } +/* accordion */ +.kw-acc { border: 1px solid #21262d; border-radius: 8px; overflow: hidden; background: #0d1117; } +.kw-acc-hdr { width: 100%; display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; background: none; border: none; cursor: pointer; text-align: left; } +.kw-acc-hdr:hover { background: rgb(255 255 255 / 0.03); } +.kw-acc-title { font-size: 0.88rem; font-weight: 600; color: #c9d1d9; flex: 1; } +.kw-acc-meta { font-size: 0.78rem; color: #6e7681; font-family: monospace; white-space: nowrap; } +.kw-acc-arrow { font-size: 0.65rem; color: #6e7681; margin-left: 0.25rem; } +.kw-acc-body { padding: 0 1rem 1rem; display: flex; flex-direction: column; gap: 0.6rem; border-top: 1px solid #21262d; } +/* tables */ +.kw-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } +.kw-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin-top: 0.4rem; } +.kw-table th { text-align: left; color: #8b949e; font-weight: 500; padding: 0.3rem 0.6rem; border-bottom: 1px solid #21262d; } +.kw-table td { padding: 0.3rem 0.6rem; color: #c9d1d9; border-bottom: 1px solid #161b22; } +.kw-table tbody tr:last-child td { border-bottom: none; } +.kw-info-table th { width: 11rem; } +.kw-row-running td { background: rgb(35 134 54 / 0.08); } +.kw-row-eol td { opacity: 0.45; } +/* badges */ +.kw-badge-running { display: inline-block; margin-left: 0.4rem; padding: 0.1rem 0.4rem; font-size: 0.7rem; font-weight: 600; border-radius: 4px; background: rgb(35 134 54 / 0.2); color: #3fb950; border: 1px solid rgb(35 134 54 / 0.4); vertical-align: middle; } +.kw-badge { display: inline-block; padding: 0.1rem 0.4rem; font-size: 0.72rem; font-weight: 500; border-radius: 4px; } +.kw-badge-installed { background: rgb(35 134 54 / 0.15); color: #3fb950; border: 1px solid rgb(35 134 54 / 0.3); } +.kw-badge-available { background: #161b22; color: #8b949e; border: 1px solid #30363d; } +.kw-type-badge { display: inline-block; padding: 0.1rem 0.4rem; font-size: 0.72rem; border-radius: 4px; background: rgb(88 166 255 / 0.1); color: #58a6ff; border: 1px solid rgb(88 166 255 / 0.2); } +.kw-src-status-cell { white-space: nowrap; } +.kw-src-status-cell .kw-badge { margin-right: 0.2rem; cursor: default; } +.kw-bl-list { margin: 0.4rem 0 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.3rem; } +.kw-bl-list li { font-size: 0.82rem; color: #c9d1d9; } +.kw-bl-list code { font-family: monospace; font-size: 0.8rem; color: #8b949e; } +.kw-row-selected td { background: rgb(56 139 253 / 0.07); } +.kw-btn { font-family: inherit; font-size: 0.76rem; padding: 0.2rem 0.55rem; border-radius: 5px; border: 1px solid #30363d; background: #161b22; color: #c9d1d9; cursor: pointer; } +.kw-btn:hover { border-color: #8b949e; color: #e6edf3; } +.kw-btn:disabled { opacity: 0.45; cursor: default; } +.kw-btn-primary { background: #1a7f37; border-color: #238636; color: #fff; } +.kw-btn-primary:hover { background: #2ea043; border-color: #2ea043; } +.kw-actions { white-space: nowrap; vertical-align: middle; } +.kw-actions-inner { display: inline-flex; gap: 0.4rem; align-items: center; } +.kw-term-wrap { margin-top: 0.75rem; border: 1px solid #21262d; border-radius: 6px; overflow: hidden; } +.kw-term-hdr { padding: 0.4rem 0.75rem; background: #161b22; font-size: 0.78rem; color: #8b949e; border-bottom: 1px solid #21262d; display: flex; gap: 1rem; } +.kw-spinner { color: #58a6ff; } +.kw-rc-ok { color: #3fb950; font-weight: 600; } +.kw-rc-err { color: #f85149; font-weight: 600; } +.kw-term { background: #010409; font-family: monospace; font-size: 0.78rem; padding: 0.6rem 0.75rem; max-height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; } +.kw-term .term-line { color: #c9d1d9; white-space: pre-wrap; word-break: break-all; line-height: 1.45; } +.kw-approval { padding: 0.6rem 0.75rem; background: rgb(187 128 9 / 0.08); border-bottom: 1px solid rgb(187 128 9 / 0.2); } +.kw-approval p { font-size: 0.8rem; color: #e3b341; margin: 0 0 0.4rem; } +.kw-approval .approval-cmd { font-size: 0.78rem; background: #161b22; padding: 0.4rem 0.6rem; border-radius: 4px; color: #c9d1d9; white-space: pre-wrap; word-break: break-all; margin: 0; } + +.kw-bl-entry { display: flex; align-items: flex-start; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid #21262d; flex-wrap: wrap; } +.kw-bl-entry:last-of-type { border-bottom: none; } +.kw-bl-info { flex: 1; display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; } +.kw-bl-label { font-size: 0.85rem; font-weight: 600; color: #c9d1d9; } +.kw-bl-cfg { font-size: 0.78rem; color: #8b949e; word-break: break-all; } +.kw-bl-action { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } +.kw-bl-limine { margin-top: 0.4rem; } +.kw-bl-limine-note { font-size: 0.8rem; color: #e3b341; margin: 0.2rem 0; } +.kw-bl-limine-cmd { font-size: 0.78rem; background: #161b22; padding: 0.35rem 0.6rem; border-radius: 4px; color: #c9d1d9; margin: 0.3rem 0 0; user-select: all; } +.kw-cleanup-bar { display: flex; align-items: center; gap: 1rem; margin-top: 0.4rem; } +.kw-btn-danger { background: #3a1a1a; border-color: #da3633; color: #f85149; } +.kw-btn-danger:hover:not(:disabled) { background: #5a2020; border-color: #f85149; } + +.kw-mf-panel { margin-top: 1.25rem; border: 1px solid #313a43; border-radius: 8px; overflow: hidden; } +.kw-mf-header { display: flex; align-items: center; gap: 0.6rem; background: #1b2026; padding: 0.6rem 1rem; border-bottom: 1px solid #313a43; } +.kw-mf-header-title { font-weight: 600; font-size: 0.9rem; color: #d7dde4; } +.kw-mf-header-src { font-size: 0.82rem; color: #88a784; flex: 1; } +.kw-mf-reset-btn { background: none; border: none; color: #6e7681; cursor: pointer; font-size: 0.9rem; padding: 0.1rem 0.3rem; border-radius: 3px; line-height: 1; } +.kw-mf-reset-btn:hover { color: #ba7f7d; background: rgba(186,127,125,0.1); } +.kw-mf-body { padding: 0 1rem 1rem; display: flex; flex-direction: column; gap: 0; } +.kw-mf-step { padding: 0.75rem 0; border-bottom: 1px solid #21262d; } +.kw-mf-step:last-child { border-bottom: none; } +.kw-mf-step-hdr { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.5rem; } +.kw-mf-step-num { display: inline-flex; align-items: center; justify-content: center; width: 1.4rem; height: 1.4rem; border-radius: 50%; background: #21262d; color: #8b949e; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; } +.kw-mf-step-title { font-size: 0.85rem; font-weight: 600; color: #c9d1d9; } +.kw-mf-step-note { font-size: 0.76rem; color: #6e7681; } +.kw-mf-step-body { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; } +.kw-mf-hint { width: 100%; font-size: 0.78rem; color: #6e7681; margin: 0.3rem 0 0; } +.kw-mf-hint code { font-family: monospace; color: #8b949e; } +.kw-mf-config-copy { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; width: 100%; } +.kw-mf-select { font-size: 0.83rem; background: #161b22; border: 1px solid #313a43; border-radius: 4px; color: #d7dde4; padding: 0.28rem 0.5rem; max-width: 320px; } +.kw-mf-select:focus { outline: none; border-color: #88a784; } +.kw-mf-kver-row { display: flex; align-items: center; gap: 0.5rem; width: 100%; margin-bottom: 0.3rem; } +.kw-mf-kver-label { font-size: 0.8rem; color: #8b949e; flex-shrink: 0; } +.kw-mf-kver-input { font-family: monospace; font-size: 0.8rem; padding: 0.25rem 0.5rem; background: #161b22; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; width: 18rem; max-width: 100%; } +.kw-mf-kver-input:focus { outline: none; border-color: #58a6ff; } +.kw-mf-step-body .kw-term { width: 100%; margin-top: 0.4rem; } +.kw-mf-sep { width: 1px; height: 1.4rem; background: #30363d; flex-shrink: 0; } +.kw-dl-note { font-size: 0.78rem; color: #6e7681; margin: 0 0 0.4rem; } + +/* Reboot panel */ +.kw-reboot-panel { margin-top: 1.5rem; padding: 1rem 1.2rem; background: rgba(186,127,125,0.06); border: 1px solid rgba(186,127,125,0.25); border-radius: 8px; } +.kw-reboot-hdr { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.4rem; color: #ba7f7d; } +.kw-reboot-note { font-size: 0.82rem; color: #98a4af; margin: 0 0 0.75rem; } +.kw-reboot-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.kw-reboot-label { font-size: 0.83rem; color: #98a4af; white-space: nowrap; } +.kw-reboot-input { font-family: monospace; font-size: 0.88rem; background: #161b22; border: 1px solid #313a43; border-radius: 4px; color: #d7dde4; padding: 0.3rem 0.6rem; width: 120px; } +.kw-reboot-input:focus { outline: none; border-color: #ba7f7d; } + +/* ── Limine config editor ────────────────────────────────────────────────── */ +.kw-limine-globals { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } +.kw-limine-global-row { display: flex; align-items: center; gap: 0.5rem; } +.kw-limine-label { font-size: 0.8rem; color: #8b949e; white-space: nowrap; } +.kw-limine-input { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; padding: 0.25rem 0.5rem; font-size: 0.82rem; } +.kw-limine-input-sm { width: 5rem; } +.kw-limine-select { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; padding: 0.25rem 0.4rem; font-size: 0.82rem; } +.kw-limine-entries { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.25rem; } +.kw-limine-entry { border: 1px solid #21262d; border-radius: 6px; overflow: hidden; } +.kw-limine-entry-raw { border-style: dashed; opacity: 0.7; } +.kw-limine-entry-raw-inner { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; font-size: 0.8rem; } +.kw-limine-raw-label { color: #8b949e; font-size: 0.78rem; } +.kw-limine-entry-linux { padding: 0.5rem 0.6rem; } +.kw-limine-entry-hdr { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } +.kw-limine-entry-num { display: inline-flex; align-items: center; justify-content: center; width: 1.4rem; height: 1.4rem; border-radius: 50%; background: #21262d; color: #8b949e; font-size: 0.72rem; font-weight: 600; flex-shrink: 0; } +.kw-limine-name-input { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; padding: 0.2rem 0.5rem; font-size: 0.85rem; font-weight: 500; } +.kw-limine-entry-actions { display: flex; gap: 0.3rem; margin-left: auto; } +.kw-btn-sm { padding: 0.15rem 0.4rem; font-size: 0.72rem; } +.kw-btn-danger-sm { background: rgb(218 54 51 / 0.1); color: #f85149; border-color: rgb(218 54 51 / 0.3); } +.kw-btn-danger-sm:hover { background: rgb(218 54 51 / 0.2); } +.kw-limine-fields { display: flex; flex-direction: column; gap: 0.3rem; } +.kw-limine-field { display: flex; align-items: center; gap: 0.5rem; } +.kw-limine-field label { font-size: 0.75rem; color: #8b949e; width: 8rem; flex-shrink: 0; } +.kw-limine-field-input { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; padding: 0.2rem 0.5rem; font-size: 0.78rem; font-family: monospace; } +.kw-limine-preview { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 0.75rem; font-size: 0.75rem; color: #8b949e; max-height: 300px; overflow: auto; white-space: pre; font-family: monospace; } +.kw-limine-save-row { display: flex; align-items: center; } + /* ── Responsive additions ────────────────────────────────────────────────── */ /* Search input (replaces inline style="width:300px") */ @@ -4819,3 +4946,88 @@ input:focus, pointer-events: none; } } + +/* ── Kernel management tabs ──────────────────────────────────────────────── */ + +/* Tab bar */ +.kw-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; +} +.kw-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 0.6rem 1.1rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + position: relative; + margin-bottom: -1px; + transition: color 0.15s; +} +.kw-tab:hover { color: var(--fg); } +.kw-tab-active { + color: var(--accent, #4f8ef7); + border-bottom-color: var(--accent, #4f8ef7); +} +.kw-tab-pip { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent, #4f8ef7); + vertical-align: middle; + margin-left: 5px; + margin-top: -2px; +} + +/* Tab panels */ +.kw-tab-panel { + min-height: 12rem; +} + +/* Two-column grid — single column on narrow, two on wide */ +.kw-two-col { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; +} +@media (min-width: 900px) { + .kw-two-col { + grid-template-columns: 1fr 1fr; + align-items: start; + } +} + +/* Section headers inside tabs (replace accordion headers) */ +.kw-section-hdr { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); + margin: 0 0 0.75rem 0; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); +} + +/* Empty build tab state */ +.kw-tab-empty { + padding: 2rem; + color: var(--fg-muted); + font-size: 0.9rem; +} +.kw-link-btn { + background: none; + border: none; + color: var(--accent, #4f8ef7); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; +} + From 7dffe096dac4f49b0d76fc742c48187e19fb767e Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 18:29:33 +0200 Subject: [PATCH 6/7] fix(security): nosemgrep urllib findings + replace deprecated urlretrieve; refactor nav to grouped dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deprecated urlretrieve with explicit urlopen streaming write; add nosemgrep directives with explanation — url is pre-validated to HTTPS + kernel.org allowlist only, file:// attack vector is impossible - Refactor primary nav from flat 11-item list to grouped dropdowns: Packages (Installed/Search/USE Flags) and System (Maintenance/ Overlays/Snapshot/Kernel) get dropdowns; Jobs/News/Advisories/ Security remain direct links - Add navTo() wrapper to avoid multi-statement Alpine CSP handlers - Add dropdown CSS (pill chevron, floating panel, item hover/active) - Revert over-aggressive floating dock changes; restore original header with only nav link items styled as pills (border-radius: 999px) Co-Authored-By: Claude Sonnet 4.6 --- backend/daemon/main.py | 16 ++++++--- frontend/alpine/app.js | 8 +++++ frontend/alpine/index.html | 67 +++++++++++++++++++++++++++++------- frontend/alpine/style.css | 70 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 142 insertions(+), 19 deletions(-) diff --git a/backend/daemon/main.py b/backend/daemon/main.py index 00f09cc..c6e77e2 100644 --- a/backend/daemon/main.py +++ b/backend/daemon/main.py @@ -3635,24 +3635,32 @@ async def cmd_kernel_download_tarball(args): yield {"line": f"WARNING: GPG signature verification skipped"} yield {"line": f"Downloading {filename} from kernel.org…"} - # Get total size via HEAD + # url is pre-validated above: HTTPS + kernel.org allowlist + tarball extension only. + # file:// and arbitrary hosts are impossible here. total = 0 try: req = _urllib.Request(url, method="HEAD") - with _urllib.urlopen(req, timeout=15) as r: + with _urllib.urlopen(req, timeout=15) as r: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected total = int(r.headers.get("Content-Length", 0)) except Exception: pass if total: yield {"line": f"File size: {total // (1024 * 1024)} MB"} - # Download in background thread, poll file size for progress + # Download in background thread, poll file size for progress. + # urlretrieve is deprecated; use urlopen with explicit streaming write. done_evt = _threading.Event() err_holder: list = [] def _dl(): try: - _urllib.urlretrieve(url, str(tarball_path)) + req = _urllib.Request(url) + with _urllib.urlopen(req, timeout=300) as resp, open(str(tarball_path), "wb") as fout: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + while True: + chunk = resp.read(256 * 1024) + if not chunk: + break + fout.write(chunk) except Exception as exc: err_holder.append(str(exc)) finally: diff --git a/frontend/alpine/app.js b/frontend/alpine/app.js index 69af233..cb85335 100644 --- a/frontend/alpine/app.js +++ b/frontend/alpine/app.js @@ -846,6 +846,14 @@ if (approvalPending(request)) _navigateToApprovalRequest(request) }) }, + openNavGroup: '', + toggleNavGroup(id) { this.openNavGroup = this.openNavGroup === id ? '' : id }, + closeNavGroup() { this.openNavGroup = '' }, + navTo(id) { this.closeNavGroup(); this.nav(id) }, + isNavGroupActive(id) { + const _g = { packages: ['packages','search','use-flags'], system: ['updates','overlays','snapshot','kernel'] } + return (_g[id] || []).indexOf(Alpine.store('router').view) !== -1 + }, isActive(id) { return isPrimaryRouteActive(id) }, nav(id) { if (Alpine.store('approvalGate').active) return diff --git a/frontend/alpine/index.html b/frontend/alpine/index.html index 6a47ccd..17cc25a 100644 --- a/frontend/alpine/index.html +++ b/frontend/alpine/index.html @@ -145,18 +145,61 @@

Arbor

diff --git a/frontend/alpine/style.css b/frontend/alpine/style.css index dc8e797..75dda21 100644 --- a/frontend/alpine/style.css +++ b/frontend/alpine/style.css @@ -4389,6 +4389,10 @@ input:focus, transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease; } +.app-primary-link { + border-radius: 999px; +} + .app-brand, .app-primary-link, .app-context-link, @@ -4432,6 +4436,66 @@ input:focus, flex: 1 1 auto; } +.app-nav-group { + position: relative; +} + +.app-nav-chevron { + display: inline-block; + font-size: 0.6rem; + margin-left: 0.2rem; + vertical-align: middle; + transition: transform 0.15s ease; +} + +.app-nav-chevron-open { + transform: rotate(180deg); +} + +.app-nav-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 50; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.3rem; + min-width: 12rem; + background: rgb(18 23 29 / 0.98); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.45); +} + +.app-nav-dropdown-item { + display: block; + width: 100%; + text-align: left; + padding: 0.45rem 0.85rem; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 0.875rem; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.12s ease, color 0.12s ease; +} + +.app-nav-dropdown-item:hover { + background: var(--hover); + color: var(--text); +} + +.app-nav-dropdown-item.active { + color: var(--primary); +} + .app-primary-link, .app-context-link, .app-logout { @@ -4440,7 +4504,7 @@ input:focus, } .app-primary-link { - padding: 0.42rem 0.72rem; + padding: 0.38rem 0.95rem; color: var(--text-subtle); } @@ -4453,8 +4517,8 @@ input:focus, } .app-primary-link.active { - background: var(--primary-surface); - border-color: rgb(136 167 132 / 0.35); + background: rgb(136 167 132 / 0.16); + border-color: rgb(136 167 132 / 0.28); color: var(--text); } From 7c01ac5430ab68f8165fdc10acc995d95e2367e2 Mon Sep 17 00:00:00 2001 From: gorecodes Date: Sun, 24 May 2026 18:32:19 +0200 Subject: [PATCH 7/7] fix(nav): remove overflow-x:auto on mobile to unblock dropdown visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overflow-x:auto on .app-primary-nav was clipping position:absolute dropdowns — they opened but were invisible. With 7 nav items the horizontal scroll is no longer needed; allow flex-wrap instead. Co-Authored-By: Claude Sonnet 4.6 --- frontend/alpine/style.css | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/alpine/style.css b/frontend/alpine/style.css index 75dda21..23b8d07 100644 --- a/frontend/alpine/style.css +++ b/frontend/alpine/style.css @@ -4651,17 +4651,11 @@ input:focus, .app-primary-nav { width: 100%; - overflow-x: auto; - padding-bottom: 0.15rem; - } - - .app-primary-nav::-webkit-scrollbar { - height: 6px; + /* overflow-x removed: would clip position:absolute dropdowns */ } .app-primary-nav ul { - min-width: max-content; - flex-wrap: nowrap; + flex-wrap: wrap; } .app-context-nav {