feat: [memory] admin panel Memories tab — browse, search, inspect, de… (#57)

* 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.
This commit is contained in:
Patrick Buckley
2026-03-14 02:42:43 -07:00
committed by GitHub
parent 67f43a7ee0
commit efd98712e9
4 changed files with 393 additions and 1 deletions
+5
View File
@@ -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]);
+248
View File
@@ -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 =
'<div class="dashboard-empty">Failed to load memories</div>';
});
}
function _renderAdminMemories(items, total) {
var el = document.getElementById("admin-memories-table");
if (!items.length) {
el.innerHTML = '<div class="dashboard-empty">No memories found</div>';
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 =
'<span class="' + typeCls + '">' + escapeHtml(m.type) + "</span>";
// 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 =
'<span class="' + scopeCls + '">' + escapeHtml(scopeLabel) + "</span>";
// Description (truncated)
var desc = m.description || "";
if (desc.length > 60) desc = desc.substring(0, 57) + "…";
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-mname">' +
escapeHtml(m.name) +
"</span>" +
'<span class="admin-col admin-col-mtype">' +
typeBadge +
"</span>" +
'<span class="admin-col admin-col-mscope">' +
scopeBadge +
"</span>" +
'<span class="admin-col admin-col-mdesc">' +
escapeHtml(desc) +
"</span>" +
'<span class="admin-col admin-col-mupdated">' +
_relativeTime(m.updated) +
"</span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-action" data-view-memory="' +
escapeHtml(m.memory_id) +
'">view</button>' +
'<button class="admin-btn-danger" data-delete-memory="' +
escapeHtml(m.memory_id) +
'" data-delete-name="' +
escapeHtml(m.name) +
'">delete</button>' +
"</span>" +
"</div>";
}
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 =
'<div class="dashboard-empty">Loading…</div>';
// 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 =
'<div class="mem-detail-grid">' +
'<div class="mem-detail-field"><span class="mem-detail-label">Name</span>' +
escapeHtml(m.name) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Type</span>' +
'<span class="scope-badge mem-type-' +
escapeHtml(m.type) +
'">' +
escapeHtml(m.type) +
"</span></div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Scope</span>' +
'<span class="scope-badge mem-scope-' +
escapeHtml(m.scope) +
'">' +
escapeHtml(scopeLabel) +
"</span></div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Created</span>' +
_relativeTime(m.created) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Updated</span>' +
_relativeTime(m.updated) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Accessed</span>' +
(m.access_count || 0) +
" times</div>" +
"</div>" +
'<div class="mem-detail-label" style="margin-top:12px">Description</div>' +
'<div class="mem-detail-desc">' +
escapeHtml(m.description || "(none)") +
"</div>" +
'<div class="mem-detail-label" style="margin-top:12px">Content</div>' +
'<pre class="memory-content-block">' +
escapeHtml(m.content) +
"</pre>";
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 =
'<div class="dashboard-empty">Failed to load memory</div>';
});
_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);
});
}
+46
View File
@@ -102,6 +102,7 @@
<div class="admin-sidebar-group-label" aria-hidden="true">Observe</div>
<button id="tab-usage" class="admin-nav" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
<button id="tab-audit" class="admin-nav" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
<button id="tab-memories" class="admin-nav" data-tab="memories" role="tab" aria-selected="false" aria-controls="admin-memories" tabindex="-1" onclick="switchAdminTab('memories')">Memories</button>
</div>
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
@@ -347,6 +348,40 @@
</div>
</div>
<!-- Memories Tab -->
<div id="admin-memories" class="admin-panel" role="tabpanel" aria-labelledby="tab-memories" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">MEMORIES</span>
<div class="admin-toolbar-filters">
<select id="mem-filter-type" onchange="loadAdminMemories()" aria-label="Filter by type">
<option value="">All types</option>
<option value="user">user</option>
<option value="project">project</option>
<option value="feedback">feedback</option>
<option value="reference">reference</option>
</select>
<select id="mem-filter-scope" onchange="loadAdminMemories()" aria-label="Filter by scope">
<option value="">All scopes</option>
<option value="global">global</option>
<option value="workstream">workstream</option>
<option value="user">user</option>
</select>
<input id="mem-search" type="search" placeholder="Search memories…" aria-label="Search memories" autocomplete="off">
</div>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-mname">NAME</span>
<span class="admin-col admin-col-mtype">TYPE</span>
<span class="admin-col admin-col-mscope">SCOPE</span>
<span class="admin-col admin-col-mdesc">DESCRIPTION</span>
<span class="admin-col admin-col-mupdated">UPDATED</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-memories-table" role="list" aria-label="Memories" aria-live="polite">
<div class="dashboard-empty">Loading…</div>
</div>
</div>
<!-- Settings Tab -->
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
<div class="admin-toolbar">
@@ -925,6 +960,17 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<div id="memory-detail-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="memory-detail-title">
<div id="memory-detail-box" class="admin-modal admin-modal-wide">
<h2 id="memory-detail-title">Memory Detail</h2>
<div id="memory-detail-body"></div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideMemoryDetailModal()">Close</button>
<button id="mem-detail-delete" class="modal-submit admin-btn-danger">Delete</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
+94 -1
View File
@@ -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; }