feat(snapshot-admin): improve tables UX and benchmark flow

This commit is contained in:
2026-06-23 18:00:33 +09:00
parent ba7b10f9a7
commit a343db5812
3 changed files with 596 additions and 70 deletions
+508 -44
View File
@@ -42,11 +42,6 @@ QUALITATIVE_SELL_BROWSABLE_TABLES = (
EDITABLE_TABLES = {
"settings",
"account_snapshot",
"collection_runs",
"collection_snapshots",
"collection_source_errors",
"sell_strategy_results",
"satellite_recommendations",
}
@@ -83,10 +78,23 @@ def list_browsable_tables(workspace_db_path: Path) -> list[dict[str, Any]]:
"row_count": row_count,
"editable": table in EDITABLE_TABLES,
})
tables.sort(key=lambda item: (
0 if item["table"] == "account_snapshot" else 1 if item["table"] == "settings" else 2,
0 if item["row_count"] else 1,
item["table"],
))
return tables
def fetch_table_rows(table: str, workspace_db_path: Path, *, limit: int = 50, offset: int = 0) -> dict[str, Any]:
def fetch_table_rows(
table: str,
workspace_db_path: Path,
*,
limit: int = 50,
offset: int = 0,
filter_text: str = "",
column_filters: dict[str, str] | None = None,
) -> dict[str, Any]:
db_path = _resolve_table_db(table, workspace_db_path)
if db_path is None:
raise ValueError(f"unknown or non-browsable table: {table}")
@@ -94,14 +102,27 @@ def fetch_table_rows(table: str, workspace_db_path: Path, *, limit: int = 50, of
return {"table": table, "db": str(db_path), "columns": [], "rows": [], "total": 0, "limit": limit, "offset": offset, "editable": table in EDITABLE_TABLES}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
total = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608 - whitelisted table name
cursor = conn.execute(
f"SELECT rowid as _rowid, * FROM {table} ORDER BY rowid DESC LIMIT ? OFFSET ?", # noqa: S608 - whitelisted table name
(limit, offset),
)
rows = [dict(row) for row in cursor.fetchall()]
cursor = conn.execute(f"SELECT rowid as _rowid, * FROM {table} ORDER BY rowid DESC",) # noqa: S608 - whitelisted table name
all_rows = [dict(row) for row in cursor.fetchall()]
columns = [description[0] for description in cursor.description] if cursor.description else []
return {"table": table, "db": str(db_path), "columns": columns, "rows": rows, "total": total, "limit": limit, "offset": offset, "editable": table in EDITABLE_TABLES}
cleaned_filter_text = str(filter_text or "").strip().lower()
normalized_column_filters = {str(key): str(value).strip().lower() for key, value in (column_filters or {}).items() if str(value).strip()}
def _match_row(row: dict[str, Any]) -> bool:
display_row = {k: v for k, v in row.items() if not str(k).startswith("_")}
haystack = json.dumps(display_row, ensure_ascii=False, default=str).lower()
if cleaned_filter_text and cleaned_filter_text not in haystack:
return False
for key, needle in normalized_column_filters.items():
cell = str(display_row.get(key, "") or "").lower()
if needle not in cell:
return False
return True
filtered_rows = [row for row in all_rows if _match_row(row)]
total = len(filtered_rows)
rows = filtered_rows[offset: offset + limit]
return {"table": table, "db": str(db_path), "columns": columns, "rows": rows, "total": total, "limit": limit, "offset": offset, "editable": table in EDITABLE_TABLES, "filter_text": cleaned_filter_text, "column_filters": normalized_column_filters}
def fetch_domain_rows(domain: str, workspace_db_path: Path) -> dict[str, Any]:
@@ -503,6 +524,9 @@ def render_index_html() -> str:
padding: 0;
vertical-align: top;
font-size: 12px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.sheet th {
position: sticky;
@@ -537,6 +561,10 @@ def render_index_html() -> str:
z-index: 1;
}
.sheet th.rownum { z-index: 3; }
.sheet th.col-wide, .sheet td.col-wide { min-width: 180px; }
.sheet th.col-xwide, .sheet td.col-xwide { min-width: 260px; }
.sheet th.col-narrow, .sheet td.col-narrow { min-width: 88px; }
.sheet th.col-micro, .sheet td.col-micro { min-width: 68px; }
.note-box {
margin-top: 12px;
display: grid;
@@ -578,6 +606,42 @@ def render_index_html() -> str:
flex-wrap: wrap;
align-items: center;
}
.top-banner {
display: grid;
gap: 8px;
margin-top: 12px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(56, 189, 248, .28);
background: linear-gradient(135deg, rgba(14, 165, 233, .18), rgba(15, 23, 42, .92));
box-shadow: 0 16px 50px rgba(15, 23, 42, .35);
}
.top-banner-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.top-banner .stat {
padding: 10px 12px;
border-radius: 12px;
background: rgba(2, 6, 23, .45);
border: 1px solid rgba(255,255,255,.08);
}
.top-banner .stat .label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: .04em;
}
.top-banner .stat strong {
display: block;
color: var(--text);
font-size: 14px;
line-height: 1.35;
word-break: break-word;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -589,12 +653,111 @@ def render_index_html() -> str:
flex-wrap: wrap;
}
.sheet tr.selected td {
background: rgba(56, 189, 248, .10);
background: rgba(14, 165, 233, .20);
box-shadow: inset 0 1px 0 rgba(186, 230, 253, .24), inset 0 -1px 0 rgba(186, 230, 253, .14);
}
.sheet tr.selected td.rownum {
background: rgba(56, 189, 248, .18);
background: rgba(14, 165, 233, .36);
color: #e0f2fe;
}
.sheet tr.selected td[contenteditable="true"] {
background: rgba(34, 211, 238, .14);
outline: 1px solid rgba(103, 232, 249, .30);
outline-offset: -1px;
font-weight: 600;
}
.sheet tr.selected td.selected-field {
background: rgba(251, 191, 36, .12);
outline-color: rgba(251, 191, 36, .42);
}
.sheet tr.selected td.selected-field:focus {
background: rgba(251, 191, 36, .16);
outline-color: rgba(251, 191, 36, .78);
box-shadow: 0 0 0 2px rgba(251, 191, 36, .18);
}
.sheet tr.selected td[contenteditable="true"]:focus {
background: rgba(103, 232, 249, .18);
outline: 2px solid rgba(125, 211, 252, .78);
outline-offset: -2px;
box-shadow: 0 0 0 2px rgba(14, 165, 233, .16);
}
.table-banner {
display: grid;
gap: 8px;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, .18);
background: rgba(15, 23, 42, .55);
}
.table-banner .meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.table-banner .muted {
color: #cbd5e1;
}
.panel.snapshot-panel {
border-color: rgba(34, 197, 94, .22);
box-shadow: 0 0 0 1px rgba(34, 197, 94, .06) inset, 0 18px 50px rgba(15, 23, 42, .18);
}
.panel.snapshot-panel .panel-head {
background: linear-gradient(90deg, rgba(34, 197, 94, .12), rgba(15, 23, 42, 0));
border-bottom: 1px solid rgba(34, 197, 94, .16);
}
.panel.snapshot-panel .status {
border-left: 3px solid rgba(34, 197, 94, .55);
background: rgba(34, 197, 94, .08);
}
.snapshot-callout {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(34, 197, 94, .18);
background: rgba(3, 7, 18, .42);
}
.snapshot-callout strong { color: #dcfce7; }
.snapshot-callout .muted { color: #bbf7d0; }
.snapshot-detail-card {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(59, 130, 246, .16);
background: linear-gradient(180deg, rgba(15, 23, 42, .72), rgba(3, 7, 18, .48));
}
.snapshot-detail-card .meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.snapshot-detail-card .field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.snapshot-detail-card .field {
padding: 8px 10px;
border-radius: 12px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.snapshot-detail-card .field strong {
display:block;
color: #dbeafe;
margin-bottom: 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .04em;
}
.diff-list {
display: grid;
gap: 8px;
@@ -788,10 +951,31 @@ def render_index_html() -> str:
<a class="btn" href="/tables">Open table browser</a>
</div>
<div class="version" id="versionSummary"></div>
<div class="top-banner" id="opsBanner" aria-live="polite">
<div class="top-banner-grid">
<div class="stat">
<span class="label">Approval</span>
<strong id="bannerApprovalSummary">Loading...</strong>
</div>
<div class="stat">
<span class="label">Lock</span>
<strong id="bannerLockSummary">Loading...</strong>
</div>
<div class="stat">
<span class="label">Selection</span>
<strong id="bannerSelectionSummary">No row selected.</strong>
</div>
<div class="stat">
<span class="label">Diff</span>
<strong id="bannerDiffSummary">Pending diff loading...</strong>
</div>
</div>
<div class="muted" id="bannerDetail">Snapshot approval state and lock state are pinned here for immediate review.</div>
</div>
</header>
<main class="wrap">
<div class="grid">
<section class="panel">
<section class="panel snapshot-panel">
<div class="panel-head">
<h2>Workspace</h2>
<div class="actions">
@@ -935,7 +1119,7 @@ def render_index_html() -> str:
<section class="panel">
<div class="panel-head">
<h2>Settings</h2>
<h2>Settings <span class="chip" id="settingsCountChip">0 rows</span></h2>
<div class="actions">
<button onclick="addSettingRow()">Add row</button>
<button class="primary" onclick="saveSettings()">Save settings</button>
@@ -968,7 +1152,7 @@ def render_index_html() -> str:
<section class="panel">
<div class="panel-head">
<h2>Account Snapshot</h2>
<h2>Account Snapshot <span class="chip" id="snapshotCountChip">0 rows</span></h2>
<div class="actions">
<button onclick="addSnapshotRow()">Add row</button>
<button class="primary" onclick="saveSnapshot()">Save snapshot</button>
@@ -979,6 +1163,10 @@ def render_index_html() -> str:
<span class="chip">Canonical column order follows spec/15_account_snapshot_contract.yaml</span>
</div>
<div class="pane-body">
<div class="snapshot-callout">
<strong>Account snapshot editing surface</strong>
<div class="muted">This panel is intentionally separated from settings so row selection, field edits, and save approval stay visually dominant.</div>
</div>
<div class="view-controls">
<input id="snapshotSortField" placeholder="sort field" list="snapshotSortFields" oninput="applyViewPreferences('account_snapshot')" />
<select id="snapshotSortDirection" onchange="applyViewPreferences('account_snapshot')">
@@ -1430,12 +1618,39 @@ def render_index_html() -> str:
return { added, removed, changed };
}
function compactRowDiff(summary, domain) {
return {
domain,
added: (summary.added || []).map((item) => ({ key: item.key, change_type: "added" })),
removed: (summary.removed || []).map((item) => ({ key: item.key, change_type: "removed" })),
changed: (summary.changed || []).map((item) => ({
key: item.key,
change_type: "changed",
cells: (item.cells || []).map((cell) => ({ field: cell.field, before: cell.before, after: cell.after })),
})),
};
}
function compressApprovalDiff(summary) {
const compressed = compactRowDiff(summary, "account_snapshot");
compressed.changed = (summary.changed || []).map((item) => ({
key: item.key,
change_type: "changed",
changed_fields: (item.cells || []).map((cell) => cell.field),
field_count: (item.cells || []).length,
}));
return compressed;
}
function buildDiffHtml(title, summary) {
const sections = [
{ label: "added", items: summary.added, tone: "good" },
{ label: "removed", items: summary.removed, tone: "danger" },
{ label: "changed", items: summary.changed, tone: "warn" },
];
const summaryLine = title.includes("account_snapshot")
? `<div class="chip">row-level preview focused on <strong>account_snapshot</strong></div>`
: "";
const blocks = sections.map((section) => {
const entries = section.items.slice(0, 5).map((item) => {
const before = item.before ? JSON.stringify(item.before, null, 2) : JSON.stringify(item.row, null, 2);
@@ -1450,7 +1665,7 @@ def render_index_html() -> str:
}).join("");
return `<div><div class="chip">${section.label}: ${section.items.length}</div>${entries || "<div class='muted'>none</div>"}</div>`;
}).join("");
return `<div class="diff-list"><div class="chip">${esc(title)}</div>${blocks}</div>`;
return `<div class="diff-list"><div class="chip">${esc(title)}</div>${summaryLine}${blocks}</div>`;
}
function buildTable(containerId, tableId, columns, rows, options) {
@@ -1484,8 +1699,12 @@ def render_index_html() -> str:
tr.dataset.rowIndex = String(rowIndex);
tr.dataset.rowRef = String(row?._row_ref || rowKey(domain, row) || "");
const currentSelection = state.selected || {};
if (domain && currentSelection.domain === domain && currentSelection.target_ref === tr.dataset.rowRef) {
const rowSelected = domain && currentSelection.domain === domain && currentSelection.target_ref === tr.dataset.rowRef;
if (rowSelected) {
tr.classList.add("selected");
if (domain === "account_snapshot") {
tr.classList.add("selected-account-snapshot");
}
}
tr.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
@@ -1503,6 +1722,9 @@ def render_index_html() -> str:
td.setAttribute("spellcheck", "false");
td.dataset.colIndex = String(colIndex);
td.textContent = cellValue(row, column);
if (rowSelected && domain === "account_snapshot") {
td.classList.add("selected-field");
}
td.addEventListener("paste", (event) => handlePaste(event, tableId, columns));
td.addEventListener("input", () => previewDiff());
td.addEventListener("focus", () => {
@@ -1742,6 +1964,10 @@ def render_index_html() -> str:
renderCollectionState();
renderSelectionInspector();
renderValidation();
const settingsCountChip = document.getElementById("settingsCountChip");
if (settingsCountChip) settingsCountChip.textContent = `${state.settingsRows.length} rows`;
const snapshotCountChip = document.getElementById("snapshotCountChip");
if (snapshotCountChip) snapshotCountChip.textContent = `${state.snapshotRows.length} rows`;
document.getElementById("workspaceStatus").innerHTML = `
<strong>DB:</strong> ${esc(state.summary.db_path || "")}
<strong>settings:</strong> ${esc(state.summary.settings_rows ?? 0)}
@@ -1900,6 +2126,7 @@ def render_index_html() -> str:
const settingsHtml = buildDiffHtml("settings pending diff", settingsDiff);
const snapshotHtml = buildDiffHtml("account_snapshot pending diff", snapshotDiff);
document.getElementById("diffPreview").innerHTML = `${settingsHtml}<hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:12px 0;">${snapshotHtml}`;
updateBannerDiffSummary(settingsDiff, snapshotDiff);
}
function buildApprovalPacket() {
@@ -1924,7 +2151,7 @@ def render_index_html() -> str:
pending_targets,
diff_preview: {
settings: settingsDiff,
account_snapshot: snapshotDiff,
account_snapshot: compressApprovalDiff(snapshotDiff),
},
approvals: state.approvalRows || [],
locks: state.locks || [],
@@ -2016,15 +2243,15 @@ def render_index_html() -> str:
document.getElementById("approvalSnapshotChip").textContent =
`account_snapshot: ${snapshotApproval.status || "MISSING"} / ${snapshotApproval.updated_at || ""}`;
const locks = state.locks || [];
document.getElementById("lockSummary").textContent =
locks.length === 0
? "No active locks."
: locks.map((lock) => `${lock.domain}:${lock.target_ref} by ${lock.locked_by || "unknown"} (${lock.locked_at})`).join(" | ");
const lockText = locks.length === 0
? "No active locks."
: locks.map((lock) => `${lock.domain}:${lock.target_ref} by ${lock.locked_by || "unknown"} (${lock.locked_at})`).join(" | ");
document.getElementById("lockSummary").textContent = lockText;
const approvalRows = state.approvalRows || [];
document.getElementById("approvalRowSummary").textContent =
approvalRows.length === 0
? "No row-level approvals."
: approvalRows.slice(0, 8).map((row) => `${row.domain}:${row.target_ref}=${row.status}`).join(" | ");
const approvalText = approvalRows.length === 0
? "No row-level approvals."
: approvalRows.slice(0, 8).map((row) => `${row.domain}:${row.target_ref}=${row.status}`).join(" | ");
document.getElementById("approvalRowSummary").textContent = approvalText;
const historyCounts = state.historyCounts || {};
const recent = (state.recentChanges || [])[0];
const changeLogFilter = String(document.getElementById("changeLogFilter")?.value || state.filterChangeLog || "").trim().toLowerCase();
@@ -2045,6 +2272,17 @@ def render_index_html() -> str:
`change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` +
(changeLogFilter ? `, filtered=${visibleChanges.length}` : "") +
(recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : "");
document.getElementById("bannerApprovalSummary").textContent =
`settings=${settingsApproval.status || "MISSING"} | snapshot=${snapshotApproval.status || "MISSING"}`;
document.getElementById("bannerLockSummary").textContent =
locks.length === 0 ? "unlocked" : `${locks.length} active lock(s)`;
document.getElementById("bannerSelectionSummary").textContent =
state.selected?.domain ? `${state.selected.domain}:${state.selected.target_ref || "*"}` : "No row selected.";
document.getElementById("bannerDetail").textContent = lockText;
updateBannerDiffSummary(
rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]),
rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns),
);
document.getElementById("changeLog").textContent = visibleChanges.map((item) => {
const beforeCount = Array.isArray(item.before_json) ? item.before_json.length : 0;
const afterCount = Array.isArray(item.after_json) ? item.after_json.length : 0;
@@ -2230,7 +2468,30 @@ def render_index_html() -> str:
const domain = state.selected.domain;
const targetRef = rowKey(domain, row);
summaryEl.textContent = `${domain}:${targetRef} | ${rowDisplayLabel(domain, row)}`;
detailEl.textContent = JSON.stringify(row, null, 2);
const bannerSelection = document.getElementById("bannerSelectionSummary");
if (bannerSelection) bannerSelection.textContent = `${domain}:${targetRef}`;
if (domain === "account_snapshot") {
const editableColumns = (state.snapshotColumns || []).filter((column) => !String(column || "").startsWith("_"));
const rowCard = `
<div class="snapshot-detail-card">
<div class="meta-row">
<span class="chip">selected account_snapshot row</span>
<span class="chip">${esc(targetRef)}</span>
</div>
<div class="field-grid">
${editableColumns.map((column) => `
<div class="field">
<strong>${esc(column)}</strong>
${esc(String(row?.[column] ?? ""))}
</div>
`).join("")}
</div>
</div>
`;
detailEl.innerHTML = rowCard;
} else {
detailEl.textContent = JSON.stringify(row, null, 2);
}
const history = (state.recentChanges || []).filter((item) => {
if (!item || item.domain !== domain) return false;
const ref = String(item.target_ref || "").trim();
@@ -2244,6 +2505,16 @@ def render_index_html() -> str:
}
}
function updateBannerDiffSummary(settingsDiff, snapshotDiff) {
const changeCount =
(settingsDiff.added || []).length + (settingsDiff.removed || []).length + (settingsDiff.changed || []).length +
(snapshotDiff.added || []).length + (snapshotDiff.removed || []).length + (snapshotDiff.changed || []).length;
const bannerDiff = document.getElementById("bannerDiffSummary");
if (bannerDiff) {
bannerDiff.textContent = `${changeCount} pending change(s) | snapshot changed rows: ${(snapshotDiff.changed || []).length}`;
}
}
function focusSelectedRow() {
const domain = state.selected.domain;
if (!domain) return;
@@ -2667,13 +2938,15 @@ def render_tables_html() -> str:
<div class="page-body">
<div class="container-xl">
<div class="card">
<div class="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="d-flex gap-2 align-items-center">
<label class="form-label mb-0 me-1" for="tableSelect">Table</label>
<select id="tableSelect" class="form-select" style="min-width:280px" onchange="onTableChange()"></select>
<span class="badge bg-secondary-lt" id="tableMeta"></span>
</div>
<div class="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="d-flex gap-2 align-items-center">
<label class="form-label mb-0 me-1" for="tableSelect">Table</label>
<select id="tableSelect" class="form-select" style="min-width:280px" onchange="onTableChange()"></select>
<span class="badge bg-secondary-lt" id="tableMeta"></span>
</div>
<div class="d-flex gap-2">
<input id="gridFilter" class="form-control form-control-sm" style="min-width:240px" placeholder="Filter current page" oninput="applyGridFilter()" />
<button class="btn btn-sm" onclick="clearGridFilters()">Clear filters</button>
<button class="btn btn-sm" onclick="prevPage()">&laquo; Prev</button>
<span class="d-flex align-items-center px-2" id="pageInfo"></span>
<button class="btn btn-sm" onclick="nextPage()">Next &raquo;</button>
@@ -2681,9 +2954,28 @@ def render_tables_html() -> str:
<button class="btn btn-sm btn-success" id="saveTableBtn" onclick="saveCurrentTable()">Save changes</button>
</div>
</div>
<div class="card-body border-top">
<div class="d-flex flex-wrap gap-2 mb-2" id="tableGroupSummary"></div>
<div class="text-secondary small">
Workspace tables are editable only when the table is in the canonical workspace DB.
Collection and strategy tables are read-only by design unless the backing store explicitly supports editing.
</div>
</div>
<div class="table-banner">
<div class="meta-row">
<span class="chip" id="tableBannerTitle">Table browser ready</span>
<span class="chip" id="tableBannerCount">0 rows</span>
<span class="chip" id="tableBannerFilter">filter=none</span>
<span class="chip" id="tableBannerPage">page=0</span>
</div>
<div class="muted" id="tableBannerDetail">If a table shows "no rows", the current filter or selected table has no visible records.</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped" id="gridTable">
<thead><tr id="gridHead"></tr></thead>
<table class="table table-vcenter card-table table-striped" id="gridTable" style="table-layout:fixed;width:100%;">
<thead>
<tr id="gridHead"></tr>
<tr id="gridFilterRow"></tr>
</thead>
<tbody id="gridBody"></tbody>
</table>
</div>
@@ -2693,7 +2985,7 @@ def render_tables_html() -> str:
</div>
</div>
<script>
const state = { tables: [], current: "", limit: 50, offset: 0, total: 0, editable: false, rows: [] };
const state = { tables: [], current: "", limit: 50, offset: 0, total: 0, editable: false, rows: [], filter: "" };
function escapeHtml(value) {
if (value === null || value === undefined) return "";
@@ -2712,19 +3004,65 @@ def render_tables_html() -> str:
}
function editableCell(rowIndex, column, value) {
return `<td contenteditable="true" data-row-index="${rowIndex}" data-column="${escapeHtml(column)}">${escapeHtml(normalizeCellValue(value))}</td>`;
return `<td class="${columnClass(state.current, column)}" contenteditable="true" data-row-index="${rowIndex}" data-column="${escapeHtml(column)}">${escapeHtml(normalizeCellValue(value))}</td>`;
}
function columnClass(tableName, column) {
const table = String(tableName || "").toLowerCase();
const key = String(column || "").toLowerCase();
if (table === "account_snapshot") {
if (["ticker"].some((token) => key.includes(token))) return "col-micro";
if (["account", "name"].some((token) => key.includes(token))) return "col-xwide";
if (["note", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
if (["parse_status", "position_type", "account_type", "status"].some((token) => key.includes(token))) return "col-wide";
if (["holding_quantity", "average_cost", "current_price", "valuation", "pl", "ordinal", "quantity", "qty"].some((token) => key.includes(token))) return "col-micro";
if (["captured_at", "updated_at", "created_at", "approved_at", "locked_at", "entry_date", "last_updated"].some((token) => key.includes(token))) return "col-wide";
}
if (table === "settings") {
if (["key"].some((token) => key.includes(token))) return "col-xwide";
if (["value", "note"].some((token) => key.includes(token))) return "col-xwide";
if (["updated_at", "created_at", "approved_at", "locked_at"].some((token) => key.includes(token))) return "col-wide";
if (["ordinal", "rowid"].some((token) => key.includes(token))) return "col-micro";
}
if (table === "workspace_change_log") {
if (["before_json", "after_json"].some((token) => key.includes(token))) return "col-xwide";
if (["domain", "action", "target_ref", "actor"].some((token) => key.includes(token))) return "col-narrow";
if (["created_at", "updated_at"].some((token) => key.includes(token))) return "col-wide";
}
if (table.includes("collection")) {
if (["run_id", "dataset_name", "source_name", "error_kind"].some((token) => key.includes(token))) return "col-xwide";
if (["ticker", "status", "stage", "state", "kind"].some((token) => key.includes(token))) return "col-narrow";
if (["created_at", "started_at", "finished_at", "captured_at", "updated_at", "last_updated"].some((token) => key.includes(token))) return "col-wide";
if (["count", "rows", "ordinal", "rowid"].some((token) => key.includes(token))) return "col-micro";
}
if (table === "sell_strategy_results" || table === "satellite_recommendations") {
if (["ticker", "name", "sector", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
if (["score", "rank", "confidence", "probability"].some((token) => key.includes(token))) return "col-micro";
if (["status", "action", "stage", "decision"].some((token) => key.includes(token))) return "col-narrow";
if (["created_at", "updated_at", "evaluated_at"].some((token) => key.includes(token))) return "col-wide";
}
if (["ticker", "account", "name", "note", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
if (["captured_at", "updated_at", "created_at", "approved_at", "locked_at", "entry_date", "last_updated"].some((token) => key.includes(token))) return "col-wide";
if (["status", "type", "stage", "flag", "parse", "confirm", "editable"].some((token) => key.includes(token))) return "col-narrow";
if (["qty", "quantity", "count", "ordinal", "rank", "rowid"].some((token) => key.includes(token))) return "col-micro";
return "";
}
async function loadTables() {
const res = await fetch("/api/tables");
const data = await res.json();
state.tables = data.tables || [];
renderTableGroupSummary();
const select = document.getElementById("tableSelect");
select.innerHTML = state.tables
.map((t) => `<option value="${t.table}" ${!t.exists ? "disabled" : ""}>${t.table} (${t.exists ? t.row_count : "no db"})${t.editable ? ' (Editable)' : ''}</option>`)
.join("");
if (!state.current && state.tables.length) {
state.current = state.tables.find((t) => t.exists)?.table || state.tables[0].table;
state.current = state.tables.find((t) => t.table === "settings" && t.exists && Number(t.row_count || 0) > 0)?.table
|| state.tables.find((t) => t.table === "account_snapshot" && t.exists && Number(t.row_count || 0) > 0)?.table
|| state.tables.find((t) => t.exists && Number(t.row_count || 0) > 0)?.table
|| state.tables.find((t) => t.exists)?.table
|| state.tables[0].table;
}
select.value = state.current;
await loadRows();
@@ -2733,6 +3071,7 @@ def render_tables_html() -> str:
function onTableChange() {
state.current = document.getElementById("tableSelect").value;
state.offset = 0;
clearGridFilters(false);
loadRows();
}
@@ -2741,15 +3080,29 @@ def render_tables_html() -> str:
const editable = state.tables.find(t => t.table === state.current)?.editable || false;
state.editable = editable;
const isDomain = isEditableDomain(state.current);
const url = isDomain ? `/api/domain_rows?domain=${encodeURIComponent(state.current)}` : `/api/table_rows?${new URLSearchParams({ table: state.current, limit: state.limit, offset: state.offset }).toString()}`;
const filterText = String(document.getElementById("gridFilter")?.value || "").trim();
const filterParams = new URLSearchParams({ table: state.current, limit: state.limit, offset: state.offset });
if (filterText) {
filterParams.set("filter", filterText);
}
for (const input of document.querySelectorAll("#gridFilterRow input[data-filter-column]")) {
const column = String(input.getAttribute("data-filter-column") || "").trim();
const value = String(input.value || "").trim();
if (column && value) {
filterParams.set(`filter_${column}`, value);
}
}
const url = isDomain ? `/api/domain_rows?domain=${encodeURIComponent(state.current)}` : `/api/table_rows?${filterParams.toString()}`;
const res = await fetch(url);
const data = await res.json();
state.rows = data.rows || [];
state.total = isDomain ? state.rows.length : (data.total || 0);
const head = document.getElementById("gridHead");
const filterRow = document.getElementById("gridFilterRow");
const body = document.getElementById("gridBody");
const displayColumns = (data.columns || []).filter((c) => !String(c).startsWith("_"));
head.innerHTML = displayColumns.map((c) => `<th>${escapeHtml(c)}</th>`).join("");
head.innerHTML = displayColumns.map((c) => `<th class="${columnClass(state.current, c)}">${escapeHtml(c)}</th>`).join("");
filterRow.innerHTML = displayColumns.map((c) => `<th class="${columnClass(state.current, c)}"><input class="form-control form-control-sm" data-filter-column="${escapeHtml(c)}" placeholder="Filter" oninput="applyGridFilter()" /></th>`).join("");
body.innerHTML = state.rows.length
? state.rows
.map((row, rowIndex) => {
@@ -2764,12 +3117,71 @@ def render_tables_html() -> str:
document.getElementById("tableMeta").textContent = `${data.db || ""}`;
const from = state.total === 0 ? 0 : state.offset + 1;
const to = Math.min(state.offset + state.limit, state.total);
const currentFilter = String(document.getElementById("gridFilter")?.value || "").trim();
const visibleCount = currentFilter
? state.rows.filter((row) => {
const haystack = displayColumns.map((c) => String(row[c] ?? "")).join(" ").toLowerCase();
return haystack.includes(currentFilter.toLowerCase());
}).length
: state.rows.length;
const bannerTitle = document.getElementById("tableBannerTitle");
const bannerCount = document.getElementById("tableBannerCount");
const bannerFilter = document.getElementById("tableBannerFilter");
const bannerPage = document.getElementById("tableBannerPage");
const bannerDetail = document.getElementById("tableBannerDetail");
if (bannerTitle) bannerTitle.textContent = `${state.current || "table"}${editable ? " (editable)" : ""}`;
if (bannerCount) bannerCount.textContent = `${state.total} rows total, ${visibleCount} visible`;
if (bannerFilter) bannerFilter.textContent = currentFilter ? `filter=${currentFilter}` : "filter=none";
if (bannerPage) bannerPage.textContent = `${from}-${to} / ${state.total}`;
if (bannerDetail) {
bannerDetail.textContent = state.total === 0
? "This table currently has no rows in the selected database."
: visibleCount === 0
? "Rows exist, but the active filter hides them."
: "Rows are loaded. Use the header filter row for per-column narrowing.";
}
document.getElementById("pageInfo").textContent = `${from}-${to} / ${state.total}`;
const saveBtn = document.getElementById("saveTableBtn");
if (saveBtn) {
saveBtn.disabled = !editable;
saveBtn.textContent = editable ? "Save current table" : "Read only";
}
applyGridFilter();
}
function renderTableGroupSummary() {
const target = document.getElementById("tableGroupSummary");
if (!target) return;
const groups = [
{ label: "Workspace", match: (table) => ["settings", "account_snapshot", "workspace_change_log", "workspace_approval_v2", "workspace_lock", "workspace_meta"].includes(table.table), tone: "primary" },
{ label: "Collection", match: (table) => ["collection_runs", "collection_snapshots", "collection_source_errors"].includes(table.table), tone: "info" },
{ label: "Strategy", match: (table) => ["sell_strategy_results", "satellite_recommendations"].includes(table.table), tone: "warning" },
];
target.innerHTML = groups.map((group) => {
const items = state.tables.filter(group.match);
const exists = items.filter((item) => item.exists).length;
const rows = items.reduce((sum, item) => sum + Number(item.row_count || 0), 0);
const active = items.some((item) => item.table === state.current);
return `<button type="button" class="btn btn${active ? '' : '-outline'}-${group.tone} btn-sm" title="${group.label} tables: ${items.map((item) => item.table).join(', ')}" onclick="focusTableGroup('${group.label}')">${group.label}: ${exists}/${items.length} tables, ${rows} rows${active ? ' • current' : ''}</button>`;
}).join("");
}
function focusTableGroup(groupLabel) {
const groupMap = {
Workspace: ["settings", "account_snapshot", "workspace_change_log", "workspace_approval_v2", "workspace_lock", "workspace_meta"],
Collection: ["collection_runs", "collection_snapshots", "collection_source_errors"],
Strategy: ["sell_strategy_results", "satellite_recommendations"],
};
const allowed = groupMap[groupLabel] || [];
const next = state.tables.find((t) => allowed.includes(t.table) && t.exists && Number(t.row_count || 0) > 0)
|| state.tables.find((t) => allowed.includes(t.table) && t.exists)
|| state.tables.find((t) => allowed.includes(t.table));
if (!next) return;
state.current = next.table;
document.getElementById("tableSelect").value = next.table;
state.offset = 0;
clearGridFilters(false);
loadRows();
}
function prevPage() {
@@ -2788,6 +3200,46 @@ def render_tables_html() -> str:
loadRows();
}
function clearGridFilters(reloadTable = true) {
const gridFilter = document.getElementById("gridFilter");
if (gridFilter) gridFilter.value = "";
document.querySelectorAll("#gridFilterRow input[data-filter-column]").forEach((input) => {
input.value = "";
});
state.filter = "";
if (reloadTable) {
state.offset = 0;
loadRows();
} else {
applyGridFilter();
}
}
function applyGridFilter() {
const text = String(document.getElementById("gridFilter")?.value || "").trim().toLowerCase();
state.filter = text;
const columnFilters = Array.from(document.querySelectorAll("#gridFilterRow input[data-filter-column]"))
.map((input) => ({
column: String(input.getAttribute("data-filter-column") || ""),
value: String(input.value || "").trim().toLowerCase(),
}))
.filter((item) => item.value);
const headers = Array.from(document.querySelectorAll("#gridHead th")).map((th) => String(th.textContent || ""));
const rows = Array.from(document.querySelectorAll("#gridBody tr[data-row-index]"));
rows.forEach((tr) => {
const cells = Array.from(tr.querySelectorAll("td"));
const haystack = tr.textContent.toLowerCase();
const matches = columnFilters.every((filter) => {
const idx = headers.indexOf(filter.column);
if (idx < 0) return true;
return String(cells[idx]?.textContent || "").toLowerCase().includes(filter.value);
});
tr.style.display = (!text || haystack.includes(text)) && matches ? "" : "none";
});
const bannerFilter = document.getElementById("tableBannerFilter");
if (bannerFilter) bannerFilter.textContent = text ? `filter=${text}` : "filter=none";
}
function collectEditableRows() {
const table = document.getElementById("gridTable");
const columns = Array.from(table.querySelectorAll("thead th")).map((th) => th.textContent || "");
@@ -2895,8 +3347,20 @@ class SnapshotAdminHandler(BaseHTTPRequestHandler):
return
limit = min(max(limit, 1), 500)
offset = max(offset, 0)
filter_text = (query.get("filter") or [""])[0]
column_filters: dict[str, str] = {}
for key, values in query.items():
if key.startswith("filter_") and values:
column_filters[key.removeprefix("filter_")] = values[0]
try:
payload = fetch_table_rows(table, self.db_path, limit=limit, offset=offset)
payload = fetch_table_rows(
table,
self.db_path,
limit=limit,
offset=offset,
filter_text=filter_text,
column_filters=column_filters,
)
except ValueError as exc:
_json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
return