From 9c4ec4855d4dce1701bef76b14825eabfcc24bd0 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 5 Jun 2026 16:31:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20L-shell=20step=203a=20=E2=80=94=20A?= =?UTF-8?q?dmin=20pane=20+=20rail=20Manage=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin becomes a singleton pane and the rail's Manage section becomes its navigation; the in-pane sidebar is retired. - shell.js registers an `admin` pane type that adopts #view-admin (the 18 tabpanels) on first open; the dashboard pane keeps #main. - admin.js: new ADMIN_IA seam (window.TS_ADMIN) — the group→tab map, a shared adminTabAllowed() gate (mirrors the legacy showAdmin permission gate, incl. the ungated node list), an active-tab subscription, and openTab. showAdmin is now a thin delegator (openPane('admin') + switchAdminTab); the in-#main view toggle, breadcrumb write, history push, and mobile-hamburger injection go. - rail.js: mountManage() builds the six collapsible .grp groups from the seam, permission-filtered, routing a row click through openTab — never touching admin DOM. - app.js: home/drill re-focus the Dashboard pane instead of blanking the moved #view-admin. - shell.css: the .grp vocabulary + admin-pane layout (in-pane sidebar hidden, #view-admin fills the pane). The legacy #admin-sidebar is hidden via CSS pending its deletion in 3b; this is the additive, independently-runnable half. Verified with a headless-Chrome harness driving the real shell.js + rail.js over a stubbed seam, plus the test_shell_js.py guards (19 passing). --- tests/test_shell_js.py | 41 ++++ turnstone/console/static/admin.js | 309 +++++++++++++++--------------- turnstone/console/static/app.js | 18 +- turnstone/shared_static/rail.js | 86 +++++++++ turnstone/shared_static/shell.css | 75 ++++++++ turnstone/shared_static/shell.js | 27 ++- 6 files changed, 388 insertions(+), 168 deletions(-) diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index 6b0eb75d..de739325 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -25,6 +25,7 @@ _SHELL_JS = _SHARED / "shell.js" _PANE_JS = _SHARED / "pane.js" _CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html" _CONSOLE_APP = _ROOT / "turnstone/console/static/app.js" +_CONSOLE_ADMIN = _ROOT / "turnstone/console/static/admin.js" _RAIL_JS = _SHARED / "rail.js" _ESM_BUNDLES = [_SHELL_JS, _PANE_JS, _RAIL_JS] @@ -178,3 +179,43 @@ def test_console_launcher_routes_by_persona() -> None: "the active-coordinators table must be removed (the rail covers it)" ) assert 'id="launcher-personas"' in index, "the persona toggle must be in the launcher panel" + + +def test_step3_admin_pane_registered_and_manage_mounted() -> None: + """Step 3: the shell registers the singleton Admin pane (which adopts + #view-admin) and mounts the rail's Manage groups from the admin IA seam.""" + body = _SHELL_JS.read_text(encoding="utf-8") + assert 'registerType("admin"' in body, "shell must register the admin pane type" + assert 'getElementById("view-admin")' in body, "the admin pane must adopt #view-admin" + assert "mountManage(" in body, "shell must mount the rail Manage groups" + assert "mountManage" in body and 'from "./rail.js"' in body, ( + "shell must import mountManage from rail.js" + ) + + +def test_step3_rail_manage_builds_from_admin_seam() -> None: + """rail.js builds the Manage groups from the TS_ADMIN seam — perm-filtered, + collapsible .grp vocabulary, programmatic DOM — and routes a row click + through the seam's openTab rather than reaching into admin DOM.""" + body = _RAIL_JS.read_text(encoding="utf-8") + assert "export function mountManage" in body, "rail must export mountManage" + assert "window.TS_ADMIN" in body, "Manage must read the admin IA seam" + assert "isTabAllowed" in body, "Manage must permission-filter its tabs" + assert "openTab" in body, "a Manage row click must route through the seam's openTab" + assert '"grp"' in body and '"grp-items"' in body, "Manage reuses the .grp vocabulary" + # collapse + active state ride a class + aria, never colour alone + assert "aria-expanded" in body, "collapsible group heads must expose aria-expanded" + + +def test_step3_admin_seam_and_thin_show_admin() -> None: + """admin.js exposes the TS_ADMIN seam (IA + shared perm gate + active-tab + + openTab) and showAdmin is now a thin delegator that opens the singleton + Admin pane — the legacy in-#main view toggle + history push are gone.""" + body = _CONSOLE_ADMIN.read_text(encoding="utf-8") + assert "const ADMIN_IA = [" in body, "admin must define the IA data" + assert "window.TS_ADMIN.ia = ADMIN_IA" in body, "admin must expose the IA seam" + assert "window.TS_ADMIN.isTabAllowed" in body and "window.TS_ADMIN.openTab" in body + assert "function adminTabAllowed(tab)" in body, "the shared permission gate must exist" + assert 'pm.openPane("admin")' in body, "showAdmin must open the singleton Admin pane" + for gone in ('currentView = "admin"', 'history.pushState({ view: "admin" }'): + assert gone not in body, f"legacy admin view-model bit {gone!r} must be gone" diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 8c45e58f..7f4f46a0 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -32,152 +32,156 @@ const ALIAS_SETTING_KEYS = [ const INHERIT_EMPTY_LABEL_KEYS = ["model.task_effort"]; // --------------------------------------------------------------------------- -// View switching (called from app.js showHome/drillDown pattern) +// Admin information architecture — the single source of truth for the rail's +// Manage groups (rail.js builds from this) AND in-pane tab activation. Each +// tab carries the permission scope that gates it, so the rail can filter +// without reaching into admin internals. `perm: null` = ungated (always +// shown — mirrors the legacy gate, which left node-metadata uncovered). +// --------------------------------------------------------------------------- +const ADMIN_IA = [ + { + group: "Identity", + tabs: [ + { tab: "users", label: "Users", perm: "admin.users" }, + { tab: "tokens", label: "API Tokens", perm: "admin.users" }, + { tab: "channels", label: "Channels", perm: "admin.users" }, + ], + }, + { + group: "Automation", + tabs: [ + { tab: "schedules", label: "Schedules", perm: "admin.schedules" }, + { tab: "watches", label: "Watches", perm: "admin.watches" }, + ], + }, + { + group: "Governance", + tabs: [ + { tab: "roles", label: "Roles", perm: "admin.roles" }, + { tab: "policies", label: "Policies", perm: "admin.policies" }, + { + tab: "prompt-policies", + label: "Prompts", + perm: "admin.prompt_policies", + }, + { tab: "judge", label: "Judge", perm: "admin.judge" }, + ], + }, + { + group: "Extensions", + tabs: [ + { tab: "skills", label: "Skills", perm: "admin.skills" }, + { tab: "mcp", label: "MCP Servers", perm: "admin.mcp" }, + ], + }, + { + group: "Observe", + tabs: [ + { tab: "usage", label: "Usage", perm: "admin.usage" }, + { tab: "audit", label: "Audit", perm: "admin.audit" }, + { tab: "memories", label: "Memories", perm: "admin.memories" }, + ], + }, + { + group: "System", + tabs: [ + { tab: "models", label: "Models", perm: "admin.models" }, + { tab: "node-metadata", label: "Nodes", perm: null }, + { tab: "settings", label: "Settings", perm: "admin.settings" }, + { tab: "tls", label: "TLS", perm: "admin.settings" }, + ], + }, +]; + +function _adminTabMeta(tab) { + for (const grp of ADMIN_IA) { + for (const t of grp.tabs) if (t.tab === tab) return t; + } + return null; +} + +// Mirror the legacy showAdmin gate exactly: only gate when a permission string +// is present (unknown perms → show everything); ungated tabs (perm: null) and +// tabs whose scope the user holds are allowed. +function adminTabAllowed(tab) { + const raw = sessionStorage.getItem("turnstone_permissions"); + if (!raw) return true; + const meta = _adminTabMeta(tab); + const needed = meta && meta.perm; + if (!needed) return true; + return raw.split(",").indexOf(needed) >= 0; +} + +function _firstAllowedAdminTab() { + for (const grp of ADMIN_IA) { + for (const t of grp.tabs) if (adminTabAllowed(t.tab)) return t.tab; + } + return null; +} + +// No-permissions empty state in the admin content host (ported from the legacy +// sidebar gate — still reachable when a user holds no admin scope at all). +function _showAdminNoPermissions() { + const panels = document.querySelectorAll(".admin-panel"); + for (let j = 0; j < panels.length; j++) panels[j].style.display = "none"; + let empty = document.getElementById("admin-no-permissions"); + if (!empty) { + empty = document.createElement("div"); + empty.id = "admin-no-permissions"; + empty.className = "dashboard-empty"; + empty.textContent = "You do not have permissions to view any admin tabs."; + const content = document.getElementById("admin-content"); + if (content) content.appendChild(empty); + } + empty.style.display = ""; +} + +// The rail's Manage groups subscribe here to mirror the active admin tab. +const _adminTabSubs = []; +function _notifyAdminTab(tab) { + for (const cb of _adminTabSubs) { + try { + cb(tab); + } catch (e) { + /* a faulty subscriber must not break tab switching */ + } + } +} + +// Seam consumed by rail.js (the Manage section): the admin IA, its permission +// gate, the active-tab subscription, the current tab, and the open-on-tab entry +// point (a row click opens/focuses the Admin pane on that tab). +window.TS_ADMIN = window.TS_ADMIN || {}; +window.TS_ADMIN.ia = ADMIN_IA; +window.TS_ADMIN.isTabAllowed = adminTabAllowed; +window.TS_ADMIN.onTabChange = function (cb) { + if (typeof cb === "function") _adminTabSubs.push(cb); +}; +window.TS_ADMIN.getActiveTab = function () { + return _adminTab; +}; +window.TS_ADMIN.openTab = function (tab) { + showAdmin(tab); +}; + +// --------------------------------------------------------------------------- +// View switching (called from app.js showHome/drillDown pattern + the rail) // --------------------------------------------------------------------------- -function showAdmin() { - /* global currentView, showHome */ - // Toggle: if already in admin view, go back to the home landing - if (currentView === "admin") { - const adminBtn = document.getElementById("admin-btn"); - if (adminBtn) { - adminBtn.classList.remove("active"); - adminBtn.setAttribute("aria-expanded", "false"); - } - showHome(); - return; - } +function showAdmin(tab) { + // The admin surface is a singleton pane now — the L-shell PaneManager owns + // show/hide and the rail's Manage groups are its navigation. Open/focus the + // pane (its onMount adopts #view-admin), then activate a tab the user may see. + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm && pm.hasType("admin")) pm.openPane("admin"); - currentView = "admin"; - const homeView = document.getElementById("view-home"); - if (homeView) homeView.style.display = "none"; - document.getElementById("view-filtered").style.display = "none"; - document.getElementById("view-admin").style.display = ""; - document.getElementById("breadcrumb").style.display = ""; - document.getElementById("breadcrumb-label").textContent = "Admin"; - document.getElementById("main").scrollTop = 0; - - // Highlight admin button as active - const adminBtn = document.getElementById("admin-btn"); - if (adminBtn) { - adminBtn.classList.add("active"); - adminBtn.setAttribute("aria-expanded", "true"); - } - history.pushState({ view: "admin" }, ""); - - // Permission gating: hide nav items the user cannot access - const perms = sessionStorage.getItem("turnstone_permissions") || ""; - const tabPerms = { - users: "admin.users", - tokens: "admin.users", - channels: "admin.users", - schedules: "admin.schedules", - watches: "admin.watches", - roles: "admin.roles", - policies: "admin.policies", - "prompt-policies": "admin.prompt_policies", - judge: "admin.judge", - skills: "admin.skills", - usage: "admin.usage", - audit: "admin.audit", - memories: "admin.memories", - settings: "admin.settings", - tls: "admin.settings", - mcp: "admin.mcp", - models: "admin.models", - }; - if (perms) { - const permSet = perms.split(","); - const navItems = document.querySelectorAll(".admin-nav"); - for (let i = 0; i < navItems.length; i++) { - const tabName = navItems[i].getAttribute("data-tab"); - const needed = tabPerms[tabName]; - if (needed && permSet.indexOf(needed) < 0) { - navItems[i].style.display = "none"; - } else { - navItems[i].style.display = ""; - } - } - } - - // Hide groups where all children are permission-hidden - const groups = document.querySelectorAll(".admin-sidebar-group"); - for (let g = 0; g < groups.length; g++) { - const visibleInGroup = groups[g].querySelectorAll( - '.admin-nav:not([style*="display: none"])', - ); - groups[g].style.display = visibleInGroup.length > 0 ? "" : "none"; - } - - // Mobile: ensure sidebar starts hidden + inert; desktop: ensure it's accessible - const sidebar = document.getElementById("admin-sidebar"); - - // Inject close header for mobile drawer (once) - if (!document.getElementById("admin-sidebar-close")) { - const closeHeader = document.createElement("div"); - closeHeader.id = "admin-sidebar-close"; - closeHeader.className = "admin-sidebar-close"; - const label = document.createElement("span"); - label.textContent = "Navigation"; - const closeBtn = document.createElement("button"); - closeBtn.setAttribute("aria-label", "Close navigation"); - closeBtn.textContent = "\u00d7"; - closeBtn.addEventListener("click", function () { - if (_mobileSidebarOpen) { - _toggleMobileSidebar(); - const mt = document.getElementById("admin-mobile-toggle"); - if (mt) mt.focus(); - } - }); - closeHeader.appendChild(label); - closeHeader.appendChild(closeBtn); - sidebar.insertBefore(closeHeader, sidebar.firstChild); - } - - if (window.innerWidth <= 700) { - _mobileSidebarOpen = false; - sidebar.classList.add("collapsed"); - sidebar.classList.remove("open"); - sidebar.setAttribute("aria-hidden", "true"); - sidebar.setAttribute("inert", ""); - } else { - sidebar.removeAttribute("aria-hidden"); - sidebar.removeAttribute("inert"); - } - - // Mobile backdrop listener (idempotent) - const backdrop = document.getElementById("admin-sidebar-backdrop"); - if (backdrop && !backdrop._listenerAttached) { - backdrop.addEventListener("click", function () { - if (_mobileSidebarOpen) { - _toggleMobileSidebar(); - const mt = document.getElementById("admin-mobile-toggle"); - if (mt) mt.focus(); - } - }); - backdrop._listenerAttached = true; - } - - // Switch to the first visible nav item - const visibleNavs = document.querySelectorAll( - '.admin-nav:not([style*="display: none"])', - ); - if (visibleNavs.length > 0) { - switchAdminTab(visibleNavs[0].getAttribute("data-tab")); - } else { - // No tabs visible — show empty state - const panels = document.querySelectorAll(".admin-panel"); - for (let j = 0; j < panels.length; j++) panels[j].style.display = "none"; - let empty = document.getElementById("admin-no-permissions"); - if (!empty) { - empty = document.createElement("div"); - empty.id = "admin-no-permissions"; - empty.className = "dashboard-empty"; - empty.textContent = "You do not have permissions to view any admin tabs."; - document.getElementById("admin-content").appendChild(empty); - } - empty.style.display = ""; - } + const target = + tab || + (_adminTab && adminTabAllowed(_adminTab) + ? _adminTab + : _firstAllowedAdminTab()); + if (target) switchAdminTab(target); + else _showAdminNoPermissions(); } function _injectMobileToggle(tab) { @@ -297,22 +301,9 @@ function switchAdminTab(tab) { const bcLabel = document.getElementById("breadcrumb-label"); if (bcLabel) bcLabel.textContent = "Admin / " + label; - // Inject mobile hamburger toggle into active panel's toolbar - _injectMobileToggle(tab); - - // On mobile, auto-close sidebar after tab selection - if (window.innerWidth <= 700 && _mobileSidebarOpen) { - _toggleMobileSidebar(); - // Move focus to the newly active panel instead of leaving it in the inert sidebar - const panel = document.getElementById("admin-" + tab); - const focusTarget = - panel && - panel.querySelector("h2, .section-header, button:not([disabled])"); - if (focusTarget) { - focusTarget.setAttribute("tabindex", "-1"); - focusTarget.focus(); - } - } + // Mirror the active tab into the rail's Manage groups (the in-pane sidebar + // that used to carry the active state is retired — the rail navigates now). + _notifyAdminTab(tab); } // --------------------------------------------------------------------------- diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 7d8f2c67..c11bec66 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -427,8 +427,10 @@ function showHome() { currentView = "home"; currentFilter = { state: null, node: null, page: 1, per_page: 50 }; _setLandingView("home"); - const adminView = document.getElementById("view-admin"); - if (adminView) adminView.style.display = "none"; + // #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered + // sub-views live inside it) instead of toggling the old in-#main admin view. + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm) pm.openPane("dashboard"); const adminBtn = document.getElementById("admin-btn"); if (adminBtn) { adminBtn.classList.remove("active"); @@ -472,8 +474,10 @@ function drillDownByState(state) { currentView = "filtered"; currentFilter = { state: state, node: null, page: 1, per_page: 50 }; _setLandingView("filtered"); - const adminView = document.getElementById("view-admin"); - if (adminView) adminView.style.display = "none"; + // #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered + // sub-views live inside it) instead of toggling the old in-#main admin view. + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm) pm.openPane("dashboard"); const adminBtn = document.getElementById("admin-btn"); if (adminBtn) { adminBtn.classList.remove("active"); @@ -497,8 +501,10 @@ function drillDownByNode(nodeId) { currentView = "filtered"; currentFilter = { state: null, node: nodeId, page: 1, per_page: 50 }; _setLandingView("filtered"); - const adminView = document.getElementById("view-admin"); - if (adminView) adminView.style.display = "none"; + // #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered + // sub-views live inside it) instead of toggling the old in-#main admin view. + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm) pm.openPane("dashboard"); const adminBtn = document.getElementById("admin-btn"); if (adminBtn) { adminBtn.classList.remove("active"); diff --git a/turnstone/shared_static/rail.js b/turnstone/shared_static/rail.js index 7001cac6..5e83a811 100644 --- a/turnstone/shared_static/rail.js +++ b/turnstone/shared_static/rail.js @@ -267,3 +267,89 @@ export function mountRail(sections, caps) { if (TS.onRender) TS.onRender(render); render(); // initial paint (empty until the first snapshot arrives) } + +// ---- Manage section (admin IA → collapsible discovery groups) -------------- + +/** + * Build the rail's Manage groups from the admin IA seam (admin.js exposes + * `window.TS_ADMIN`). Each group is a collapsible `.grp` whose head toggles + * its `.grp-items`; items are the admin tabs the user is permitted to see. A + * row click opens/focuses the singleton Admin pane on that tab via the seam's + * `openTab`. The IA is static, so this builds once; only the active-row marker + * re-renders, driven by the admin tab-change subscription (single writer). + * + * House style: programmatic DOM, NO innerHTML; reuses the mock's + * `.grp`/`.grp-head`/`.grp-items` vocabulary (shell.css). + */ +export function mountManage(root) { + if (!root) return; + const TS = window.TS_ADMIN || {}; + const ia = TS.ia || []; + const allowed = TS.isTabAllowed || (() => true); + root.replaceChildren(); + + const rowByTab = new Map(); // tab -> its row