mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user