feat(snapshot-admin): improve tables UX and benchmark flow
This commit is contained in:
@@ -42,11 +42,6 @@ QUALITATIVE_SELL_BROWSABLE_TABLES = (
|
|||||||
EDITABLE_TABLES = {
|
EDITABLE_TABLES = {
|
||||||
"settings",
|
"settings",
|
||||||
"account_snapshot",
|
"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,
|
"row_count": row_count,
|
||||||
"editable": table in EDITABLE_TABLES,
|
"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
|
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)
|
db_path = _resolve_table_db(table, workspace_db_path)
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
raise ValueError(f"unknown or non-browsable table: {table}")
|
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}
|
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:
|
with sqlite3.connect(db_path) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
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",) # noqa: S608 - whitelisted table name
|
||||||
cursor = conn.execute(
|
all_rows = [dict(row) for row in cursor.fetchall()]
|
||||||
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()]
|
|
||||||
columns = [description[0] for description in cursor.description] if cursor.description else []
|
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]:
|
def fetch_domain_rows(domain: str, workspace_db_path: Path) -> dict[str, Any]:
|
||||||
@@ -503,6 +524,9 @@ def render_index_html() -> str:
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.sheet th {
|
.sheet th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -537,6 +561,10 @@ def render_index_html() -> str:
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.sheet th.rownum { z-index: 3; }
|
.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 {
|
.note-box {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -578,6 +606,42 @@ def render_index_html() -> str:
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
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 {
|
.two-col {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -589,12 +653,111 @@ def render_index_html() -> str:
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.sheet tr.selected td {
|
.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 {
|
.sheet tr.selected td.rownum {
|
||||||
background: rgba(56, 189, 248, .18);
|
background: rgba(14, 165, 233, .36);
|
||||||
color: #e0f2fe;
|
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 {
|
.diff-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -788,10 +951,31 @@ def render_index_html() -> str:
|
|||||||
<a class="btn" href="/tables">Open table browser</a>
|
<a class="btn" href="/tables">Open table browser</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="version" id="versionSummary"></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>
|
</header>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="panel">
|
<section class="panel snapshot-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Workspace</h2>
|
<h2>Workspace</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -935,7 +1119,7 @@ def render_index_html() -> str:
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Settings</h2>
|
<h2>Settings <span class="chip" id="settingsCountChip">0 rows</span></h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="addSettingRow()">Add row</button>
|
<button onclick="addSettingRow()">Add row</button>
|
||||||
<button class="primary" onclick="saveSettings()">Save settings</button>
|
<button class="primary" onclick="saveSettings()">Save settings</button>
|
||||||
@@ -968,7 +1152,7 @@ def render_index_html() -> str:
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Account Snapshot</h2>
|
<h2>Account Snapshot <span class="chip" id="snapshotCountChip">0 rows</span></h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="addSnapshotRow()">Add row</button>
|
<button onclick="addSnapshotRow()">Add row</button>
|
||||||
<button class="primary" onclick="saveSnapshot()">Save snapshot</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>
|
<span class="chip">Canonical column order follows spec/15_account_snapshot_contract.yaml</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-body">
|
<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">
|
<div class="view-controls">
|
||||||
<input id="snapshotSortField" placeholder="sort field" list="snapshotSortFields" oninput="applyViewPreferences('account_snapshot')" />
|
<input id="snapshotSortField" placeholder="sort field" list="snapshotSortFields" oninput="applyViewPreferences('account_snapshot')" />
|
||||||
<select id="snapshotSortDirection" onchange="applyViewPreferences('account_snapshot')">
|
<select id="snapshotSortDirection" onchange="applyViewPreferences('account_snapshot')">
|
||||||
@@ -1430,12 +1618,39 @@ def render_index_html() -> str:
|
|||||||
return { added, removed, changed };
|
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) {
|
function buildDiffHtml(title, summary) {
|
||||||
const sections = [
|
const sections = [
|
||||||
{ label: "added", items: summary.added, tone: "good" },
|
{ label: "added", items: summary.added, tone: "good" },
|
||||||
{ label: "removed", items: summary.removed, tone: "danger" },
|
{ label: "removed", items: summary.removed, tone: "danger" },
|
||||||
{ label: "changed", items: summary.changed, tone: "warn" },
|
{ 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 blocks = sections.map((section) => {
|
||||||
const entries = section.items.slice(0, 5).map((item) => {
|
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);
|
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("");
|
}).join("");
|
||||||
return `<div><div class="chip">${section.label}: ${section.items.length}</div>${entries || "<div class='muted'>none</div>"}</div>`;
|
return `<div><div class="chip">${section.label}: ${section.items.length}</div>${entries || "<div class='muted'>none</div>"}</div>`;
|
||||||
}).join("");
|
}).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) {
|
function buildTable(containerId, tableId, columns, rows, options) {
|
||||||
@@ -1484,8 +1699,12 @@ def render_index_html() -> str:
|
|||||||
tr.dataset.rowIndex = String(rowIndex);
|
tr.dataset.rowIndex = String(rowIndex);
|
||||||
tr.dataset.rowRef = String(row?._row_ref || rowKey(domain, row) || "");
|
tr.dataset.rowRef = String(row?._row_ref || rowKey(domain, row) || "");
|
||||||
const currentSelection = state.selected || {};
|
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");
|
tr.classList.add("selected");
|
||||||
|
if (domain === "account_snapshot") {
|
||||||
|
tr.classList.add("selected-account-snapshot");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tr.addEventListener("click", (event) => {
|
tr.addEventListener("click", (event) => {
|
||||||
if (event.target.closest("button")) return;
|
if (event.target.closest("button")) return;
|
||||||
@@ -1503,6 +1722,9 @@ def render_index_html() -> str:
|
|||||||
td.setAttribute("spellcheck", "false");
|
td.setAttribute("spellcheck", "false");
|
||||||
td.dataset.colIndex = String(colIndex);
|
td.dataset.colIndex = String(colIndex);
|
||||||
td.textContent = cellValue(row, column);
|
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("paste", (event) => handlePaste(event, tableId, columns));
|
||||||
td.addEventListener("input", () => previewDiff());
|
td.addEventListener("input", () => previewDiff());
|
||||||
td.addEventListener("focus", () => {
|
td.addEventListener("focus", () => {
|
||||||
@@ -1742,6 +1964,10 @@ def render_index_html() -> str:
|
|||||||
renderCollectionState();
|
renderCollectionState();
|
||||||
renderSelectionInspector();
|
renderSelectionInspector();
|
||||||
renderValidation();
|
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 = `
|
document.getElementById("workspaceStatus").innerHTML = `
|
||||||
<strong>DB:</strong> ${esc(state.summary.db_path || "")}
|
<strong>DB:</strong> ${esc(state.summary.db_path || "")}
|
||||||
<strong>settings:</strong> ${esc(state.summary.settings_rows ?? 0)}
|
<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 settingsHtml = buildDiffHtml("settings pending diff", settingsDiff);
|
||||||
const snapshotHtml = buildDiffHtml("account_snapshot pending diff", snapshotDiff);
|
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}`;
|
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() {
|
function buildApprovalPacket() {
|
||||||
@@ -1924,7 +2151,7 @@ def render_index_html() -> str:
|
|||||||
pending_targets,
|
pending_targets,
|
||||||
diff_preview: {
|
diff_preview: {
|
||||||
settings: settingsDiff,
|
settings: settingsDiff,
|
||||||
account_snapshot: snapshotDiff,
|
account_snapshot: compressApprovalDiff(snapshotDiff),
|
||||||
},
|
},
|
||||||
approvals: state.approvalRows || [],
|
approvals: state.approvalRows || [],
|
||||||
locks: state.locks || [],
|
locks: state.locks || [],
|
||||||
@@ -2016,15 +2243,15 @@ def render_index_html() -> str:
|
|||||||
document.getElementById("approvalSnapshotChip").textContent =
|
document.getElementById("approvalSnapshotChip").textContent =
|
||||||
`account_snapshot: ${snapshotApproval.status || "MISSING"} / ${snapshotApproval.updated_at || ""}`;
|
`account_snapshot: ${snapshotApproval.status || "MISSING"} / ${snapshotApproval.updated_at || ""}`;
|
||||||
const locks = state.locks || [];
|
const locks = state.locks || [];
|
||||||
document.getElementById("lockSummary").textContent =
|
const lockText = locks.length === 0
|
||||||
locks.length === 0
|
? "No active locks."
|
||||||
? "No active locks."
|
: locks.map((lock) => `${lock.domain}:${lock.target_ref} by ${lock.locked_by || "unknown"} (${lock.locked_at})`).join(" | ");
|
||||||
: 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 || [];
|
const approvalRows = state.approvalRows || [];
|
||||||
document.getElementById("approvalRowSummary").textContent =
|
const approvalText = approvalRows.length === 0
|
||||||
approvalRows.length === 0
|
? "No row-level approvals."
|
||||||
? "No row-level approvals."
|
: approvalRows.slice(0, 8).map((row) => `${row.domain}:${row.target_ref}=${row.status}`).join(" | ");
|
||||||
: approvalRows.slice(0, 8).map((row) => `${row.domain}:${row.target_ref}=${row.status}`).join(" | ");
|
document.getElementById("approvalRowSummary").textContent = approvalText;
|
||||||
const historyCounts = state.historyCounts || {};
|
const historyCounts = state.historyCounts || {};
|
||||||
const recent = (state.recentChanges || [])[0];
|
const recent = (state.recentChanges || [])[0];
|
||||||
const changeLogFilter = String(document.getElementById("changeLogFilter")?.value || state.filterChangeLog || "").trim().toLowerCase();
|
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}` +
|
`change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` +
|
||||||
(changeLogFilter ? `, filtered=${visibleChanges.length}` : "") +
|
(changeLogFilter ? `, filtered=${visibleChanges.length}` : "") +
|
||||||
(recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : "");
|
(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) => {
|
document.getElementById("changeLog").textContent = visibleChanges.map((item) => {
|
||||||
const beforeCount = Array.isArray(item.before_json) ? item.before_json.length : 0;
|
const beforeCount = Array.isArray(item.before_json) ? item.before_json.length : 0;
|
||||||
const afterCount = Array.isArray(item.after_json) ? item.after_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 domain = state.selected.domain;
|
||||||
const targetRef = rowKey(domain, row);
|
const targetRef = rowKey(domain, row);
|
||||||
summaryEl.textContent = `${domain}:${targetRef} | ${rowDisplayLabel(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) => {
|
const history = (state.recentChanges || []).filter((item) => {
|
||||||
if (!item || item.domain !== domain) return false;
|
if (!item || item.domain !== domain) return false;
|
||||||
const ref = String(item.target_ref || "").trim();
|
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() {
|
function focusSelectedRow() {
|
||||||
const domain = state.selected.domain;
|
const domain = state.selected.domain;
|
||||||
if (!domain) return;
|
if (!domain) return;
|
||||||
@@ -2667,13 +2938,15 @@ def render_tables_html() -> str:
|
|||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
<div class="container-xl">
|
<div class="container-xl">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
<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">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<label class="form-label mb-0 me-1" for="tableSelect">Table</label>
|
<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>
|
<select id="tableSelect" class="form-select" style="min-width:280px" onchange="onTableChange()"></select>
|
||||||
<span class="badge bg-secondary-lt" id="tableMeta"></span>
|
<span class="badge bg-secondary-lt" id="tableMeta"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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>
|
<button class="btn btn-sm" onclick="prevPage()">« Prev</button>
|
||||||
<span class="d-flex align-items-center px-2" id="pageInfo"></span>
|
<span class="d-flex align-items-center px-2" id="pageInfo"></span>
|
||||||
<button class="btn btn-sm" onclick="nextPage()">Next »</button>
|
<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>
|
<button class="btn btn-sm btn-success" id="saveTableBtn" onclick="saveCurrentTable()">Save changes</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="table-responsive">
|
||||||
<table class="table table-vcenter card-table table-striped" id="gridTable">
|
<table class="table table-vcenter card-table table-striped" id="gridTable" style="table-layout:fixed;width:100%;">
|
||||||
<thead><tr id="gridHead"></tr></thead>
|
<thead>
|
||||||
|
<tr id="gridHead"></tr>
|
||||||
|
<tr id="gridFilterRow"></tr>
|
||||||
|
</thead>
|
||||||
<tbody id="gridBody"></tbody>
|
<tbody id="gridBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -2693,7 +2985,7 @@ def render_tables_html() -> str:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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) {
|
function escapeHtml(value) {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
@@ -2712,19 +3004,65 @@ def render_tables_html() -> str:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editableCell(rowIndex, column, value) {
|
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() {
|
async function loadTables() {
|
||||||
const res = await fetch("/api/tables");
|
const res = await fetch("/api/tables");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
state.tables = data.tables || [];
|
state.tables = data.tables || [];
|
||||||
|
renderTableGroupSummary();
|
||||||
const select = document.getElementById("tableSelect");
|
const select = document.getElementById("tableSelect");
|
||||||
select.innerHTML = state.tables
|
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>`)
|
.map((t) => `<option value="${t.table}" ${!t.exists ? "disabled" : ""}>${t.table} (${t.exists ? t.row_count : "no db"})${t.editable ? ' (Editable)' : ''}</option>`)
|
||||||
.join("");
|
.join("");
|
||||||
if (!state.current && state.tables.length) {
|
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;
|
select.value = state.current;
|
||||||
await loadRows();
|
await loadRows();
|
||||||
@@ -2733,6 +3071,7 @@ def render_tables_html() -> str:
|
|||||||
function onTableChange() {
|
function onTableChange() {
|
||||||
state.current = document.getElementById("tableSelect").value;
|
state.current = document.getElementById("tableSelect").value;
|
||||||
state.offset = 0;
|
state.offset = 0;
|
||||||
|
clearGridFilters(false);
|
||||||
loadRows();
|
loadRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2741,15 +3080,29 @@ def render_tables_html() -> str:
|
|||||||
const editable = state.tables.find(t => t.table === state.current)?.editable || false;
|
const editable = state.tables.find(t => t.table === state.current)?.editable || false;
|
||||||
state.editable = editable;
|
state.editable = editable;
|
||||||
const isDomain = isEditableDomain(state.current);
|
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 res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
state.rows = data.rows || [];
|
state.rows = data.rows || [];
|
||||||
state.total = isDomain ? state.rows.length : (data.total || 0);
|
state.total = isDomain ? state.rows.length : (data.total || 0);
|
||||||
const head = document.getElementById("gridHead");
|
const head = document.getElementById("gridHead");
|
||||||
|
const filterRow = document.getElementById("gridFilterRow");
|
||||||
const body = document.getElementById("gridBody");
|
const body = document.getElementById("gridBody");
|
||||||
const displayColumns = (data.columns || []).filter((c) => !String(c).startsWith("_"));
|
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
|
body.innerHTML = state.rows.length
|
||||||
? state.rows
|
? state.rows
|
||||||
.map((row, rowIndex) => {
|
.map((row, rowIndex) => {
|
||||||
@@ -2764,12 +3117,71 @@ def render_tables_html() -> str:
|
|||||||
document.getElementById("tableMeta").textContent = `${data.db || ""}`;
|
document.getElementById("tableMeta").textContent = `${data.db || ""}`;
|
||||||
const from = state.total === 0 ? 0 : state.offset + 1;
|
const from = state.total === 0 ? 0 : state.offset + 1;
|
||||||
const to = Math.min(state.offset + state.limit, state.total);
|
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}`;
|
document.getElementById("pageInfo").textContent = `${from}-${to} / ${state.total}`;
|
||||||
const saveBtn = document.getElementById("saveTableBtn");
|
const saveBtn = document.getElementById("saveTableBtn");
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
saveBtn.disabled = !editable;
|
saveBtn.disabled = !editable;
|
||||||
saveBtn.textContent = editable ? "Save current table" : "Read only";
|
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() {
|
function prevPage() {
|
||||||
@@ -2788,6 +3200,46 @@ def render_tables_html() -> str:
|
|||||||
loadRows();
|
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() {
|
function collectEditableRows() {
|
||||||
const table = document.getElementById("gridTable");
|
const table = document.getElementById("gridTable");
|
||||||
const columns = Array.from(table.querySelectorAll("thead th")).map((th) => th.textContent || "");
|
const columns = Array.from(table.querySelectorAll("thead th")).map((th) => th.textContent || "");
|
||||||
@@ -2895,8 +3347,20 @@ class SnapshotAdminHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
limit = min(max(limit, 1), 500)
|
limit = min(max(limit, 1), 500)
|
||||||
offset = max(offset, 0)
|
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:
|
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:
|
except ValueError as exc:
|
||||||
_json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
|
_json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
|
|||||||
self.assertIn("contenteditable", html)
|
self.assertIn("contenteditable", html)
|
||||||
self.assertIn("/api/settings/save", html)
|
self.assertIn("/api/settings/save", html)
|
||||||
self.assertIn("/api/account_snapshot/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 target", html)
|
||||||
self.assertIn("Lock row", html)
|
self.assertIn("Lock row", html)
|
||||||
self.assertIn("Approve pending", html)
|
self.assertIn("Approve pending", html)
|
||||||
@@ -74,6 +80,16 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
|
|||||||
self.assertIn("/collection", html)
|
self.assertIn("/collection", html)
|
||||||
self.assertIn("Open collection dashboard", 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):
|
def test_render_collection_html_contains_dashboard_surface(self):
|
||||||
html = render_collection_html()
|
html = render_collection_html()
|
||||||
self.assertIn("KIS Collection Dashboard", 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"))
|
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
||||||
self.assertTrue(workflow.exists())
|
self.assertTrue(workflow.exists())
|
||||||
self.assertIn("--reload", package["scripts"]["ops:snapshot-web"])
|
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-validate", package["scripts"])
|
||||||
self.assertIn("ops:snapshot-web-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("/api/domain_rows", html)
|
||||||
self.assertIn("saveCurrentTable", html)
|
self.assertIn("saveCurrentTable", html)
|
||||||
self.assertIn("gridTable", 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):
|
def test_list_browsable_tables_covers_all_three_databases(self):
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -141,6 +162,8 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
|
|||||||
|
|
||||||
tables = list_browsable_tables(db_path)
|
tables = list_browsable_tables(db_path)
|
||||||
names = {row["table"] for row in tables}
|
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({"settings", "account_snapshot", "workspace_change_log"} <= names)
|
||||||
self.assertTrue({"collection_runs", "collection_snapshots", "collection_source_errors"} <= names)
|
self.assertTrue({"collection_runs", "collection_snapshots", "collection_source_errors"} <= names)
|
||||||
self.assertTrue({"sell_strategy_results", "satellite_recommendations"} <= 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)
|
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
|
||||||
self.assertNotEqual(page1["rows"], page2["rows"])
|
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):
|
with self.assertRaises(ValueError):
|
||||||
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
|
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -9,27 +9,28 @@ WBS-9.2: snapshot_admin 성능 벤치마크 도구
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import requests
|
|
||||||
import statistics
|
import statistics
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
import sys
|
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
|
# Config
|
||||||
ADMIN_URL = "http://localhost:5000/api"
|
ADMIN_URL = "http://127.0.0.1:8787/api"
|
||||||
TABLES = [
|
TABLES = [
|
||||||
"positions",
|
"settings",
|
||||||
"data_feed",
|
"account_snapshot",
|
||||||
"macro",
|
"workspace_change_log",
|
||||||
"performance",
|
"workspace_approval_v2",
|
||||||
"orders",
|
"workspace_lock",
|
||||||
"cash_positions",
|
"workspace_meta"
|
||||||
"portfolio_summary",
|
|
||||||
"risk_metrics",
|
|
||||||
"sector_allocation",
|
|
||||||
"sector_flows"
|
|
||||||
]
|
]
|
||||||
NUM_RUNS = 10
|
NUM_RUNS = 10
|
||||||
CONCURRENT_LIMIT = 10
|
CONCURRENT_LIMIT = 10
|
||||||
@@ -49,15 +50,22 @@ class PerformanceBenchmark:
|
|||||||
|
|
||||||
def _call_table(self, table_name: str) -> Tuple[str, float, int]:
|
def _call_table(self, table_name: str) -> Tuple[str, float, int]:
|
||||||
"""Call a single table API endpoint and return timing."""
|
"""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:
|
try:
|
||||||
start = time.time()
|
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
|
elapsed_ms = (time.time() - start) * 1000
|
||||||
status = response.status_code
|
status = 200
|
||||||
return table_name, elapsed_ms, status
|
return table_name, elapsed_ms, status
|
||||||
except Exception as e:
|
except (URLError, HTTPError, TimeoutError, OSError):
|
||||||
return table_name, None, 0
|
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):
|
def benchmark_single_table(self, table_name: str, num_runs: int = NUM_RUNS):
|
||||||
"""Benchmark a single table with multiple runs."""
|
"""Benchmark a single table with multiple runs."""
|
||||||
@@ -128,7 +136,7 @@ class PerformanceBenchmark:
|
|||||||
"p99_table_ms": round(sorted_concurrent[p99_idx], 2),
|
"p99_table_ms": round(sorted_concurrent[p99_idx], 2),
|
||||||
"per_table_times": {k: round(v, 2) for k, v in results_map.items()},
|
"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"
|
"status": "PASS" if sorted_concurrent[p99_idx] <= P99_TARGET_MS else "SLOW"
|
||||||
}
|
}
|
||||||
|
|
||||||
def generate_summary(self):
|
def generate_summary(self):
|
||||||
"""Generate summary statistics."""
|
"""Generate summary statistics."""
|
||||||
@@ -165,11 +173,11 @@ class PerformanceBenchmark:
|
|||||||
for table_name in sorted(self.results["tables"].keys()):
|
for table_name in sorted(self.results["tables"].keys()):
|
||||||
r = self.results["tables"][table_name]
|
r = self.results["tables"][table_name]
|
||||||
if r["status"] in ["PASS", "SLOW"]:
|
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 "
|
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)")
|
f"(mean: {r['mean_ms']:7.2f}ms, max: {r['max_ms']:7.2f}ms)")
|
||||||
else:
|
else:
|
||||||
print(f"✗ {table_name:25} FAILED ({r['errors']} errors)")
|
print(f"[FAIL] {table_name:25} FAILED ({r['errors']} errors)")
|
||||||
|
|
||||||
# Concurrent performance
|
# Concurrent performance
|
||||||
if "parallel_load" in self.results["concurrent"]:
|
if "parallel_load" in self.results["concurrent"]:
|
||||||
@@ -187,7 +195,7 @@ class PerformanceBenchmark:
|
|||||||
print(f"Status: {s['overall_status']}")
|
print(f"Status: {s['overall_status']}")
|
||||||
print(f"Passed: {s['passed']}/{s['total_tables']} tables")
|
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"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")
|
print("=" * 70 + "\n")
|
||||||
|
|
||||||
def save_report(self, output_file: str = None):
|
def save_report(self, output_file: str = None):
|
||||||
@@ -211,7 +219,7 @@ class PerformanceBenchmark:
|
|||||||
print("Phase 1: Single table performance...")
|
print("Phase 1: Single table performance...")
|
||||||
for table in self.tables:
|
for table in self.tables:
|
||||||
self.benchmark_single_table(table, NUM_RUNS)
|
self.benchmark_single_table(table, NUM_RUNS)
|
||||||
print(f" ✓ {table}")
|
print(f" [OK] {table}")
|
||||||
|
|
||||||
# Concurrent benchmark
|
# Concurrent benchmark
|
||||||
print(f"\nPhase 2: Concurrent load ({CONCURRENT_LIMIT} tables)...")
|
print(f"\nPhase 2: Concurrent load ({CONCURRENT_LIMIT} tables)...")
|
||||||
@@ -227,6 +235,18 @@ class PerformanceBenchmark:
|
|||||||
return self.results
|
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]:
|
def optimize_recommendations(results: Dict) -> List[str]:
|
||||||
"""Generate optimization recommendations."""
|
"""Generate optimization recommendations."""
|
||||||
recommendations = []
|
recommendations = []
|
||||||
@@ -257,16 +277,31 @@ def optimize_recommendations(results: Dict) -> List[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not recommendations:
|
if not recommendations:
|
||||||
recommendations.append("✓ Performance meets targets. Continue monitoring.")
|
recommendations.append("[OK] Performance meets targets. Continue monitoring.")
|
||||||
|
|
||||||
return recommendations
|
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__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
# Check server availability
|
# Check server availability
|
||||||
response = requests.head(f"{ADMIN_URL}/health", timeout=2)
|
response = requests.get(f"{ADMIN_URL}/tables", timeout=2)
|
||||||
if response.status_code not in [200, 404]:
|
if response.status_code != 200:
|
||||||
print(f"Error: snapshot_admin not available at {ADMIN_URL}")
|
print(f"Error: snapshot_admin not available at {ADMIN_URL}")
|
||||||
print("Start server: python tools/run_snapshot_admin_server_v1.py")
|
print("Start server: python tools/run_snapshot_admin_server_v1.py")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -283,7 +318,7 @@ if __name__ == "__main__":
|
|||||||
print("OPTIMIZATION RECOMMENDATIONS:")
|
print("OPTIMIZATION RECOMMENDATIONS:")
|
||||||
print("-" * 70)
|
print("-" * 70)
|
||||||
for rec in optimize_recommendations(results):
|
for rec in optimize_recommendations(results):
|
||||||
print(f"• {rec}")
|
print(f"- {rec}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Exit code based on target met
|
# Exit code based on target met
|
||||||
|
|||||||
Reference in New Issue
Block a user