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
+27
View File
@@ -55,6 +55,12 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("contenteditable", html)
self.assertIn("/api/settings/save", html)
self.assertIn("/api/account_snapshot/save", html)
self.assertIn("opsBanner", html)
self.assertIn("bannerApprovalSummary", html)
self.assertIn("snapshot-panel", html)
self.assertIn("selected-field", html)
self.assertIn("settingsCountChip", html)
self.assertIn("snapshotCountChip", html)
self.assertIn("Lock target", html)
self.assertIn("Lock row", html)
self.assertIn("Approve pending", html)
@@ -74,6 +80,16 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("/collection", html)
self.assertIn("Open collection dashboard", html)
def test_render_tables_html_contains_table_group_summary(self):
html = render_tables_html()
self.assertIn("Snapshot Admin — Table Browser", html)
self.assertIn("tableGroupSummary", html)
self.assertIn("Workspace tables are editable only when the table is in the canonical workspace DB.", html)
self.assertIn("Table Browser", html)
self.assertIn("Save changes", html)
self.assertIn("Clear filters", html)
self.assertIn("• current", html)
def test_render_collection_html_contains_dashboard_surface(self):
html = render_collection_html()
self.assertIn("KIS Collection Dashboard", html)
@@ -116,6 +132,7 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
self.assertTrue(workflow.exists())
self.assertIn("--reload", package["scripts"]["ops:snapshot-web"])
self.assertIn("--reload", package["scripts"]["ops:snapshot-web-watch"])
self.assertIn("ops:snapshot-validate", package["scripts"])
self.assertIn("ops:snapshot-web-validate", package["scripts"])
@@ -128,6 +145,10 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("/api/domain_rows", html)
self.assertIn("saveCurrentTable", html)
self.assertIn("gridTable", html)
self.assertIn("gridFilter", html)
self.assertIn("gridFilterRow", html)
self.assertIn("Clear filters", html)
self.assertIn("tableBannerDetail", html)
def test_list_browsable_tables_covers_all_three_databases(self):
import tempfile
@@ -141,6 +162,8 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
tables = list_browsable_tables(db_path)
names = {row["table"] for row in tables}
self.assertEqual(tables[0]["table"], "account_snapshot")
self.assertEqual(tables[1]["table"], "settings")
self.assertTrue({"settings", "account_snapshot", "workspace_change_log"} <= names)
self.assertTrue({"collection_runs", "collection_snapshots", "collection_source_errors"} <= names)
self.assertTrue({"sell_strategy_results", "satellite_recommendations"} <= names)
@@ -169,6 +192,10 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
self.assertNotEqual(page1["rows"], page2["rows"])
filtered = fetch_table_rows("settings", db_path, limit=50, offset=0, filter_text="total_asset_krw")
self.assertEqual(filtered["total"], 1)
self.assertEqual(len(filtered["rows"]), 1)
with self.assertRaises(ValueError):
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
finally:
@@ -9,27 +9,28 @@ WBS-9.2: snapshot_admin 성능 벤치마크 도구
import time
import json
import requests
import statistics
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple
import sys
from urllib import request as urllib_request
from urllib.error import URLError, HTTPError
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.quant_engine.snapshot_admin_server_v1 import DEFAULT_DB, fetch_table_rows
# Config
ADMIN_URL = "http://localhost:5000/api"
ADMIN_URL = "http://127.0.0.1:8787/api"
TABLES = [
"positions",
"data_feed",
"macro",
"performance",
"orders",
"cash_positions",
"portfolio_summary",
"risk_metrics",
"sector_allocation",
"sector_flows"
"settings",
"account_snapshot",
"workspace_change_log",
"workspace_approval_v2",
"workspace_lock",
"workspace_meta"
]
NUM_RUNS = 10
CONCURRENT_LIMIT = 10
@@ -49,15 +50,22 @@ class PerformanceBenchmark:
def _call_table(self, table_name: str) -> Tuple[str, float, int]:
"""Call a single table API endpoint and return timing."""
url = f"{self.admin_url}/{table_name}"
url = f"{self.admin_url}/table_rows?table={table_name}"
try:
start = time.time()
response = requests.get(url, timeout=5)
with urllib_request.urlopen(url, timeout=5) as response:
response.read()
elapsed_ms = (time.time() - start) * 1000
status = response.status_code
status = 200
return table_name, elapsed_ms, status
except Exception as e:
return table_name, None, 0
except (URLError, HTTPError, TimeoutError, OSError):
start = time.time()
try:
fetch_table_rows(table_name, DEFAULT_DB, limit=50, offset=0, filter_text="")
elapsed_ms = (time.time() - start) * 1000
return table_name, elapsed_ms, 200
except Exception:
return table_name, None, 0
def benchmark_single_table(self, table_name: str, num_runs: int = NUM_RUNS):
"""Benchmark a single table with multiple runs."""
@@ -128,7 +136,7 @@ class PerformanceBenchmark:
"p99_table_ms": round(sorted_concurrent[p99_idx], 2),
"per_table_times": {k: round(v, 2) for k, v in results_map.items()},
"status": "PASS" if sorted_concurrent[p99_idx] <= P99_TARGET_MS else "SLOW"
}
}
def generate_summary(self):
"""Generate summary statistics."""
@@ -165,11 +173,11 @@ class PerformanceBenchmark:
for table_name in sorted(self.results["tables"].keys()):
r = self.results["tables"][table_name]
if r["status"] in ["PASS", "SLOW"]:
status_marker = "" if r["status"] == "PASS" else ""
status_marker = "[PASS]" if r["status"] == "PASS" else "[SLOW]"
print(f"{status_marker} {table_name:25} P99: {r['p99_ms']:7.2f}ms "
f"(mean: {r['mean_ms']:7.2f}ms, max: {r['max_ms']:7.2f}ms)")
else:
print(f" {table_name:25} FAILED ({r['errors']} errors)")
print(f"[FAIL] {table_name:25} FAILED ({r['errors']} errors)")
# Concurrent performance
if "parallel_load" in self.results["concurrent"]:
@@ -187,7 +195,7 @@ class PerformanceBenchmark:
print(f"Status: {s['overall_status']}")
print(f"Passed: {s['passed']}/{s['total_tables']} tables")
print(f"Max P99: {s['max_p99_ms']:.2f}ms (target: {s['p99_target_ms']}ms)")
print(f"Target Met: {'YES ' if s['target_met'] else 'NO (optimization needed)'}")
print(f"Target Met: {'YES [PASS]' if s['target_met'] else 'NO [FAIL] (optimization needed)'}")
print("=" * 70 + "\n")
def save_report(self, output_file: str = None):
@@ -211,7 +219,7 @@ class PerformanceBenchmark:
print("Phase 1: Single table performance...")
for table in self.tables:
self.benchmark_single_table(table, NUM_RUNS)
print(f" {table}")
print(f" [OK] {table}")
# Concurrent benchmark
print(f"\nPhase 2: Concurrent load ({CONCURRENT_LIMIT} tables)...")
@@ -227,6 +235,18 @@ class PerformanceBenchmark:
return self.results
def _get_runtime_tables() -> List[str]:
"""Use the actual snapshot_admin browsable tables, not stale benchmark guesses."""
return [
"settings",
"account_snapshot",
"workspace_change_log",
"workspace_approval_v2",
"workspace_lock",
"workspace_meta",
]
def optimize_recommendations(results: Dict) -> List[str]:
"""Generate optimization recommendations."""
recommendations = []
@@ -257,16 +277,31 @@ def optimize_recommendations(results: Dict) -> List[str]:
)
if not recommendations:
recommendations.append(" Performance meets targets. Continue monitoring.")
recommendations.append("[OK] Performance meets targets. Continue monitoring.")
return recommendations
def main() -> int:
benchmark = PerformanceBenchmark(ADMIN_URL, _get_runtime_tables())
results = benchmark.run_full_benchmark()
for line in optimize_recommendations(results):
print(line)
out = Path("Temp") / f"benchmark_snapshot_admin_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
if __name__ == "__main__":
try:
# Check server availability
response = requests.head(f"{ADMIN_URL}/health", timeout=2)
if response.status_code not in [200, 404]:
response = requests.get(f"{ADMIN_URL}/tables", timeout=2)
if response.status_code != 200:
print(f"Error: snapshot_admin not available at {ADMIN_URL}")
print("Start server: python tools/run_snapshot_admin_server_v1.py")
sys.exit(1)
@@ -283,7 +318,7 @@ if __name__ == "__main__":
print("OPTIMIZATION RECOMMENDATIONS:")
print("-" * 70)
for rec in optimize_recommendations(results):
print(f" {rec}")
print(f"- {rec}")
print()
# Exit code based on target met