From efd98712e97ea5b8a56fe09baf7a08f7f29605d0 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 14 Mar 2026 02:42:43 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20[memory]=20admin=20panel=20Memories=20t?= =?UTF-8?q?ab=20=E2=80=94=20browse,=20search,=20inspect,=20de=E2=80=A6=20(?= =?UTF-8?q?#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: [memory] admin panel Memories tab — browse, search, inspect, delete Add 13th admin tab in the Observe group for cluster-wide memory management. List view with type/scope filter dropdowns and debounced search input. Detail modal shows full metadata grid and scrollable content block. Delete from both list row and detail modal with confirmation and audit trail. Permission-gated behind admin.memories. Escape key, backdrop click, and focus trap wired for the detail modal. Mobile responsive: hides description and updated columns below 700px. * fix: memory detail modal — focus, delete safety, CSS shorthand order Address Copilot review feedback: move focus to close button on modal open for keyboard accessibility, disable delete button and clear stale handler during loading/error states to prevent wrong-memory deletion, and fix font shorthand/font-size ordering in toolbar filter styles. --- turnstone/console/static/admin.js | 5 + turnstone/console/static/governance.js | 248 +++++++++++++++++++++++++ turnstone/console/static/index.html | 46 +++++ turnstone/console/static/style.css | 95 +++++++++- 4 files changed, 393 insertions(+), 1 deletion(-) diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 3691afc5..2fcfa03f 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -63,6 +63,7 @@ function showAdmin() { "ws-templates": "admin.ws_templates", usage: "admin.usage", audit: "admin.audit", + memories: "admin.memories", settings: "admin.users", }; if (perms) { @@ -191,6 +192,7 @@ function switchAdminTab(tab) { "ws-templates", "usage", "audit", + "memories", "settings", ]; for (var p = 0; p < panels.length; p++) { @@ -212,6 +214,7 @@ function switchAdminTab(tab) { _populateAuditUserFilter(); loadGovAudit(); } + if (tab === "memories") loadAdminMemories(); if (tab === "settings") loadSettings(); // Update breadcrumb with active tab label @@ -1589,6 +1592,7 @@ function _installTrap(overlayId, boxId, trapRef) { hideCreateWsTemplateModal(); else if (overlayId === "edit-wst-overlay") hideEditWsTemplateModal(); else if (overlayId === "wst-history-overlay") hideWstHistoryModal(); + else if (overlayId === "memory-detail-overlay") hideMemoryDetailModal(); } }; } @@ -1667,6 +1671,7 @@ document.addEventListener("keydown", function (e) { ["create-wst-overlay", hideCreateWsTemplateModal], ["edit-wst-overlay", hideEditWsTemplateModal], ["wst-history-overlay", hideWstHistoryModal], + ["memory-detail-overlay", hideMemoryDetailModal], ]; for (var gi = 0; gi < govOverlays.length; gi++) { var govEl = document.getElementById(govOverlays[gi][0]); diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js index 3e47deab..1a2b6c6c 100644 --- a/turnstone/console/static/governance.js +++ b/turnstone/console/static/governance.js @@ -1636,3 +1636,251 @@ function _populateAuditUserFilter() { } sel.innerHTML = html; } + +// --------------------------------------------------------------------------- +// Memories tab +// --------------------------------------------------------------------------- + +var _adminMemories = []; +var _memDetailTrap = null; +var _memDetailTrigger = null; +var _memSearchTimer = null; +var _memSearchBound = false; + +function loadAdminMemories() { + clearTimeout(_memSearchTimer); + // Bind search debounce on first load + if (!_memSearchBound) { + var searchEl = document.getElementById("mem-search"); + if (searchEl) { + searchEl.addEventListener("input", function () { + clearTimeout(_memSearchTimer); + _memSearchTimer = setTimeout(loadAdminMemories, 300); + }); + } + _memSearchBound = true; + } + + var memType = document.getElementById("mem-filter-type").value; + var scope = document.getElementById("mem-filter-scope").value; + var query = (document.getElementById("mem-search").value || "").trim(); + + var url; + if (query) { + url = + "/v1/api/admin/memories/search?q=" + + encodeURIComponent(query) + + (memType ? "&type=" + encodeURIComponent(memType) : "") + + (scope ? "&scope=" + encodeURIComponent(scope) : ""); + } else { + url = + "/v1/api/admin/memories?limit=200" + + (memType ? "&type=" + encodeURIComponent(memType) : "") + + (scope ? "&scope=" + encodeURIComponent(scope) : ""); + } + + authFetch(url) + .then(function (r) { + if (!r.ok) throw new Error("Failed to load memories"); + return r.json(); + }) + .then(function (data) { + _adminMemories = data.memories || []; + _renderAdminMemories(_adminMemories, data.total || _adminMemories.length); + }) + .catch(function () { + document.getElementById("admin-memories-table").innerHTML = + '
Failed to load memories
'; + }); +} + +function _renderAdminMemories(items, total) { + var el = document.getElementById("admin-memories-table"); + if (!items.length) { + el.innerHTML = '
No memories found
'; + return; + } + + var html = ""; + for (var i = 0; i < items.length; i++) { + var m = items[i]; + + // Type badge + var typeCls = "scope-badge mem-type-" + escapeHtml(m.type); + var typeBadge = + '' + escapeHtml(m.type) + ""; + + // Scope badge + var scopeLabel = m.scope; + if (m.scope_id) scopeLabel += ":" + m.scope_id; + var scopeCls = "scope-badge mem-scope-" + escapeHtml(m.scope); + var scopeBadge = + '' + escapeHtml(scopeLabel) + ""; + + // Description (truncated) + var desc = m.description || ""; + if (desc.length > 60) desc = desc.substring(0, 57) + "…"; + + html += + '
' + + '' + + escapeHtml(m.name) + + "" + + '' + + typeBadge + + "" + + '' + + scopeBadge + + "" + + '' + + escapeHtml(desc) + + "" + + '' + + _relativeTime(m.updated) + + "" + + '' + + '' + + '' + + "" + + "
"; + } + + el.innerHTML = html; + + // Bind view buttons + var viewBtns = el.querySelectorAll("[data-view-memory]"); + for (var v = 0; v < viewBtns.length; v++) { + viewBtns[v].addEventListener("click", function () { + showMemoryDetailModal(this.getAttribute("data-view-memory")); + }); + } + + // Bind delete buttons + var delBtns = el.querySelectorAll("[data-delete-memory]"); + for (var d = 0; d < delBtns.length; d++) { + delBtns[d].addEventListener("click", function () { + var mid = this.getAttribute("data-delete-memory"); + var mname = this.getAttribute("data-delete-name"); + deleteAdminMemory(mid, mname); + }); + } +} + +function showMemoryDetailModal(memoryId) { + _memDetailTrigger = document.activeElement; + var ov = document.getElementById("memory-detail-overlay"); + ov.style.display = "flex"; + document.getElementById("memory-detail-body").innerHTML = + '
Loading…
'; + + // Disable delete button and clear stale handler while loading + var delBtn = document.getElementById("mem-detail-delete"); + delBtn.disabled = true; + delBtn.onclick = null; + + // Focus close button for keyboard accessibility + var closeBtn = ov.querySelector(".modal-cancel"); + if (closeBtn) closeBtn.focus(); + + authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId)) + .then(function (r) { + if (!r.ok) throw new Error("Not found"); + return r.json(); + }) + .then(function (m) { + var scopeLabel = m.scope; + if (m.scope_id) scopeLabel += ":" + m.scope_id; + + var html = + '
' + + '
Name' + + escapeHtml(m.name) + + "
" + + '
Type' + + '' + + escapeHtml(m.type) + + "
" + + '
Scope' + + '' + + escapeHtml(scopeLabel) + + "
" + + '
Created' + + _relativeTime(m.created) + + "
" + + '
Updated' + + _relativeTime(m.updated) + + "
" + + '
Accessed' + + (m.access_count || 0) + + " times
" + + "
" + + '
Description
' + + '
' + + escapeHtml(m.description || "(none)") + + "
" + + '
Content
' + + '
' +
+        escapeHtml(m.content) +
+        "
"; + + document.getElementById("memory-detail-body").innerHTML = html; + + // Wire delete button now that data is loaded + delBtn.disabled = false; + delBtn.onclick = function () { + deleteAdminMemory(m.memory_id, m.name); + }; + }) + .catch(function () { + document.getElementById("memory-detail-body").innerHTML = + '
Failed to load memory
'; + }); + + _memDetailTrap = _installTrap("memory-detail-overlay", "memory-detail-box"); +} + +function hideMemoryDetailModal() { + document.getElementById("memory-detail-overlay").style.display = "none"; + _memDetailTrap = _removeTrap(_memDetailTrap); + if (_memDetailTrigger && _memDetailTrigger.focus) _memDetailTrigger.focus(); + _memDetailTrigger = null; +} + +function deleteAdminMemory(memoryId, memoryName) { + if (!confirm("Delete memory '" + memoryName + "'?")) return; + + authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId), { + method: "DELETE", + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + showToast("Memory deleted"); + // Close detail modal if open + if ( + document.getElementById("memory-detail-overlay").style.display !== + "none" + ) { + hideMemoryDetailModal(); + } + loadAdminMemories(); + }) + .catch(function (e) { + showToast("Error: " + e.message); + }); +} diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 3260c223..4ebeaf43 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -102,6 +102,7 @@ +
@@ -347,6 +348,40 @@
+ + + + + diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index 8e39978e..d229e644 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -1168,7 +1168,8 @@ #create-role-overlay, #edit-role-overlay, #user-roles-overlay, #create-policy-overlay, #edit-policy-overlay, #create-template-overlay, #edit-template-overlay, -#create-wst-overlay, #edit-wst-overlay, #wst-history-overlay { +#create-wst-overlay, #edit-wst-overlay, #wst-history-overlay, +#memory-detail-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); @@ -1326,6 +1327,91 @@ .audit-danger { color: var(--red); border-color: var(--red-glow); } .audit-success { color: var(--green); border-color: var(--green-glow); } +/* ========================================================================== + Memories tab + ========================================================================== */ +#admin-memories .admin-colheaders, +#admin-memories .admin-row { + grid-template-columns: 1.5fr 80px 110px 1fr 100px 80px; +} + +/* Filter toolbar */ +.admin-toolbar-filters { + display: flex; + gap: 8px; + margin-left: auto; + align-items: center; +} +.admin-toolbar-filters select, +.admin-toolbar-filters input[type="search"] { + border: 1px solid var(--border); + border-radius: 3px; + padding: 4px 8px; + font: inherit; + font-size: 12px; +} +.admin-toolbar-filters select { + min-width: 0; +} +.admin-toolbar-filters input[type="search"] { + width: 180px; + background: var(--bg); + color: var(--fg); +} +.admin-toolbar-filters input[type="search"]::placeholder { + color: var(--fg-dim); +} + +/* Memory type badges */ +.mem-type-project { background: var(--bg-highlight); color: var(--cyan); border-color: var(--cyan-glow, var(--border)); } +.mem-type-user { background: var(--bg-highlight); color: var(--green); border-color: var(--green-glow, var(--border)); } +.mem-type-feedback { background: var(--bg-highlight); color: var(--yellow); border-color: var(--yellow-glow, var(--border)); } +.mem-type-reference { background: var(--bg-highlight); color: var(--magenta); border-color: var(--magenta-glow, var(--border)); } + +/* Memory scope badges */ +.mem-scope-global { background: var(--bg-highlight); color: var(--fg-dim); } +.mem-scope-workstream { background: var(--bg-highlight); color: var(--cyan); border-color: var(--cyan-glow, var(--border)); } +.mem-scope-user { background: var(--bg-highlight); color: var(--green); border-color: var(--green-glow, var(--border)); } + +/* Memory detail modal */ +.mem-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px 16px; +} +.mem-detail-field { + display: flex; + flex-direction: column; + gap: 2px; +} +.mem-detail-label { + font-size: 10px; + font-family: var(--font-display); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); +} +.mem-detail-desc { + color: var(--fg); + font-size: 13px; + padding: 4px 0; +} +.memory-content-block { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: 13px; + color: var(--fg); + margin: 4px 0 0; +} + /* ========================================================================== Governance: Usage dashboard ========================================================================== */ @@ -1495,6 +1581,13 @@ grid-template-columns: 60px 1fr 100px; } .admin-col-auser, .admin-col-adetail { display: none; } + #admin-memories .admin-colheaders, #admin-memories .admin-row { + grid-template-columns: 1fr 70px 90px 80px; + } + .admin-col-mdesc, .admin-col-mupdated { display: none; } + .admin-toolbar-filters { flex-wrap: wrap; } + .admin-toolbar-filters input[type="search"] { width: 120px; } + .mem-detail-grid { grid-template-columns: 1fr 1fr; } .usage-readout-value { font-size: 18px; } .usage-bar-row { grid-template-columns: 70px 1fr 50px; } .perm-grid { grid-template-columns: 1fr; }