feat(snapshot-admin): improve tables UX and benchmark flow
This commit is contained in:
@@ -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()">« Prev</button>
|
||||
<span class="d-flex align-items-center px-2" id="pageInfo"></span>
|
||||
<button class="btn btn-sm" onclick="nextPage()">Next »</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
|
||||
|
||||
Reference in New Issue
Block a user