Files
QuantEngineByItz/src/quant_engine/snapshot_admin_server_v1.py
T

3636 lines
156 KiB
Python

from __future__ import annotations
import argparse
import json
import sqlite3
import subprocess
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from hashlib import sha256
from typing import Any
from urllib.parse import urlparse, parse_qs
ROOT = Path(__file__).resolve().parents[2]
SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6"
KIS_COLLECTION_DB = ROOT / "src" / "quant_engine" / "kis_data_collection.db"
KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json"
QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db"
# WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은
# SQL에 절대 보간되지 않는다(요청 테이블명을 그대로 SELECT 문에 넣지 않고
# 아래 레지스트리 키와 정확히 일치할 때만 허용).
WORKSPACE_BROWSABLE_TABLES = (
"settings",
"account_snapshot",
"workspace_change_log",
"workspace_approval_v2",
"workspace_lock",
"workspace_meta",
)
COLLECTION_BROWSABLE_TABLES = (
"collection_runs",
"collection_snapshots",
"collection_source_errors",
)
QUALITATIVE_SELL_BROWSABLE_TABLES = (
"sell_strategy_results",
"satellite_recommendations",
)
# Editable tables configurations (WBS requirement 2)
EDITABLE_TABLES = {
"settings",
"account_snapshot",
}
def _resolve_table_db(table: str, workspace_db_path: Path) -> Path | None:
if table in WORKSPACE_BROWSABLE_TABLES:
return Path(workspace_db_path)
if table in COLLECTION_BROWSABLE_TABLES:
return KIS_COLLECTION_DB
if table in QUALITATIVE_SELL_BROWSABLE_TABLES:
return QUALITATIVE_SELL_DB
return None
def list_browsable_tables(workspace_db_path: Path) -> list[dict[str, Any]]:
tables: list[dict[str, Any]] = []
for table in (
*WORKSPACE_BROWSABLE_TABLES,
*COLLECTION_BROWSABLE_TABLES,
*QUALITATIVE_SELL_BROWSABLE_TABLES,
):
db_path = _resolve_table_db(table, workspace_db_path)
exists = bool(db_path and db_path.exists())
row_count = 0
if exists:
try:
with sqlite3.connect(db_path) as conn:
row_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608 - table is whitelist-checked above
except sqlite3.OperationalError:
exists = False
tables.append({
"table": table,
"db": str(db_path) if db_path else "",
"exists": exists,
"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,
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}")
if not db_path.exists():
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
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 []
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]:
if domain == "settings":
rows = load_settings_rows(workspace_db_path)
return {"domain": domain, "db": str(workspace_db_path), "columns": ["ordinal", "key", "value", "note", "updated_at"], "rows": rows}
if domain == "account_snapshot":
rows = load_account_snapshot_rows(workspace_db_path)
return {
"domain": domain,
"db": str(workspace_db_path),
"columns": list(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS),
"rows": rows,
}
raise ValueError(f"unknown editable domain: {domain}")
SNAPSHOT_ADMIN_VERSION_FILES = (
ROOT / "src" / "quant_engine" / "snapshot_admin_server_v1.py",
ROOT / "src" / "quant_engine" / "snapshot_admin_store_v1.py",
ROOT / "src" / "quant_engine" / "data_collection_store_v1.py",
ROOT / "tools" / "run_snapshot_admin_server_v1.py",
ROOT / "tools" / "validate_snapshot_admin_web_v1.py",
ROOT / "tests" / "unit" / "test_snapshot_admin_web_v1.py",
ROOT / "package.json",
)
from .snapshot_admin_store_v1 import (
ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
DEFAULT_DB,
DEFAULT_SEED_JSON,
export_payload,
clear_lock,
import_seed_json,
is_locked,
load_account_snapshot_rows,
load_approval_for_domain,
load_approval_rows,
load_change_log_rows,
load_locks,
load_settings_rows,
normalize_db_path,
now_kst_iso,
open_connection,
parse_account_snapshot_tsv,
parse_scalar,
record_change_log,
validate_account_snapshot_rows,
validate_settings_rows,
build_validation_suggestions,
build_safe_autofix_actions,
apply_safe_autofix_action,
lock_conflicts_for_rows,
set_approval,
set_lock,
replace_account_snapshot,
replace_settings,
undo_last_change,
summarize_workspace,
)
from .data_collection_store_v1 import load_collection_dashboard_state
def _strip_internal_fields(row: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in row.items() if not key.startswith("_")}
def _snapshot_columns_from_rows(rows: list[dict[str, Any]]) -> list[str]:
columns = list(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)
extras = sorted(
{
key
for row in rows
for key in row.keys()
if not key.startswith("_") and key not in ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
}
)
for key in extras:
if key not in columns:
columns.append(key)
return columns
def _write_json(path: Path, payload: dict[str, Any]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def _render_approval_packet_md(packet: dict[str, Any]) -> str:
pending = packet.get("pending_targets") if isinstance(packet.get("pending_targets"), list) else []
summary = packet.get("summary") if isinstance(packet.get("summary"), dict) else {}
lines = [
"# Snapshot Admin Approval Packet",
"",
"## Summary",
"",
f"- settings_changed: {summary.get('settings_changed', 0)}",
f"- account_snapshot_changed: {summary.get('account_snapshot_changed', 0)}",
f"- pending_target_count: {summary.get('pending_target_count', 0)}",
"",
"## Pending Targets",
"",
]
if pending:
for item in pending[:100]:
if not isinstance(item, dict):
continue
lines.append(f"- {item.get('domain', '')}:{item.get('target_ref', '')} ({item.get('change_type', '')})")
else:
lines.append("_none_")
return "\n".join(lines)
def write_approval_packet_artifacts(packet: dict[str, Any]) -> dict[str, str]:
json_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.json"
md_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.md"
_write_json(json_path, packet)
md_path.parent.mkdir(parents=True, exist_ok=True)
md_path.write_text(_render_approval_packet_md(packet), encoding="utf-8")
return {"json_path": str(json_path), "md_path": str(md_path)}
def _git_info() -> dict[str, Any]:
try:
commit = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(ROOT),
text=True,
stderr=subprocess.DEVNULL,
).strip()
status = subprocess.check_output(
["git", "status", "--porcelain"],
cwd=str(ROOT),
text=True,
stderr=subprocess.DEVNULL,
)
return {
"commit": commit,
"dirty": bool(status.strip()),
"tree_state": "DIRTY" if status.strip() else "CLEAN",
}
except Exception:
return {
"commit": "",
"dirty": False,
"tree_state": "UNKNOWN",
}
def _source_fingerprint() -> dict[str, Any]:
digest = sha256()
latest_mtime = 0.0
for path in SNAPSHOT_ADMIN_VERSION_FILES:
if not path.exists():
continue
try:
data = path.read_bytes()
digest.update(path.as_posix().encode("utf-8"))
digest.update(b"\0")
digest.update(data)
latest_mtime = max(latest_mtime, path.stat().st_mtime)
except OSError:
continue
return {
"fingerprint": digest.hexdigest()[:16],
"latest_mtime": latest_mtime,
}
def _approval_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
ensure_schema(conn)
row = conn.execute(
f"""
SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
FROM {APPROVAL_TABLE}
WHERE domain = ? AND target_ref = ?
LIMIT 1
""",
(domain, target_ref or "*"),
).fetchone()
return dict(row) if row is not None else None
def _lock_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
ensure_schema(conn)
row = conn.execute(
f"""
SELECT domain, target_ref, locked_by, reason, locked_at
FROM {LOCK_TABLE}
WHERE domain = ? AND target_ref = ?
LIMIT 1
""",
(domain, target_ref or "*"),
).fetchone()
return dict(row) if row is not None else None
def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]:
summary = summarize_workspace(db_path)
settings_rows = load_settings_rows(db_path)
account_rows = [_strip_internal_fields(row) for row in load_account_snapshot_rows(db_path)]
settings_errors = validate_settings_rows(settings_rows)
snapshot_errors = validate_account_snapshot_rows(account_rows)
suggestions = build_validation_suggestions(settings_rows, account_rows)
autofix_actions = build_safe_autofix_actions(settings_rows, account_rows)
try:
collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT)
except Exception:
collection = {}
return {
"version": {
"app": SNAPSHOT_ADMIN_VERSION,
"git": _git_info(),
"source": _source_fingerprint(),
},
"summary": summary,
"approval_rows": load_approval_rows(db_path),
"approval_settings": load_approval_for_domain(db_path, "settings"),
"approval_account_snapshot": load_approval_for_domain(db_path, "account_snapshot"),
"locks": load_locks(db_path),
"recent_changes": load_change_log_rows(db_path, limit=12),
"history_counts": {
"changes": len(load_change_log_rows(db_path, limit=200)),
"approvals": len(load_approval_rows(db_path)),
"locks": len(load_locks(db_path)),
},
"settings_rows": settings_rows,
"account_snapshot_rows": account_rows,
"account_snapshot_columns": _snapshot_columns_from_rows(account_rows),
"validation": {
"settings": settings_errors,
"account_snapshot": snapshot_errors,
"suggestions": suggestions,
},
"autofix_actions": autofix_actions,
"collection": collection,
"generated_at": now_kst_iso(),
}
def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None:
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
body = text.encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", content_type)
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
length = int(handler.headers.get("Content-Length") or "0")
raw = handler.rfile.read(length).decode("utf-8") if length else "{}"
payload = json.loads(raw or "{}")
if not isinstance(payload, dict):
raise ValueError("JSON body must be an object")
return payload
def render_index_html() -> str:
return """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snapshot Admin</title>
<style>
:root {
color-scheme: light;
--bg: #0f172a;
--panel: #111827;
--panel-2: #0b1220;
--line: #243047;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #38bdf8;
--accent-2: #22c55e;
--danger: #fb7185;
--warn: #f59e0b;
--chip: #172036;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans KR", sans-serif;
background:
radial-gradient(circle at top left, rgba(56,189,248,.10), transparent 28%),
radial-gradient(circle at top right, rgba(34,197,94,.08), transparent 20%),
linear-gradient(180deg, #08101f 0%, #0b1220 100%);
color: var(--text);
}
header {
padding: 24px 24px 14px;
border-bottom: 1px solid rgba(255,255,255,.06);
background: rgba(5, 10, 20, .5);
position: sticky;
top: 0;
backdrop-filter: blur(10px);
z-index: 10;
}
h1 { margin: 0; font-size: 22px; letter-spacing: .01em; }
.subline { color: var(--muted); margin-top: 6px; font-size: 13px; }
.wrap { padding: 20px 24px 40px; max-width: 1600px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
}
.panel {
border: 1px solid rgba(255,255,255,.08);
background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95));
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0,0,0,.24);
overflow: hidden;
}
.panel-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 10px;
border-bottom: 1px solid rgba(255,255,255,.06);
}
.panel-head h2 { margin: 0; font-size: 16px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
button, .btn {
border: 1px solid rgba(255,255,255,.12);
background: var(--chip);
color: var(--text);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
font-size: 13px;
}
button.primary { background: linear-gradient(135deg, var(--accent), #0ea5e9); color: #fff; }
button.good { background: linear-gradient(135deg, var(--accent-2), #16a34a); color: #fff; }
button.danger { background: linear-gradient(135deg, #f87171, #fb7185); color: #fff; }
button:hover { filter: brightness(1.06); }
.status {
padding: 10px 16px;
color: var(--muted);
border-bottom: 1px solid rgba(255,255,255,.06);
font-size: 13px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.version {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--muted);
}
.status strong { color: var(--text); }
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 0;
}
.filter-row input {
background: rgba(3, 7, 18, .45);
color: var(--text);
border: 1px solid rgba(255,255,255,.10);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
min-width: 180px;
}
.tiny {
font-size: 11px;
color: var(--muted);
}
.pane-body { padding: 14px 16px 18px; }
.grid-wrap {
overflow: auto;
border: 1px solid rgba(255,255,255,.08);
border-radius: 14px;
background: rgba(3, 7, 18, .45);
}
table.sheet {
width: 100%;
border-collapse: collapse;
min-width: 100%;
}
.sheet th, .sheet td {
border-bottom: 1px solid rgba(255,255,255,.06);
border-right: 1px solid rgba(255,255,255,.04);
padding: 0;
vertical-align: top;
font-size: 12px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.sheet th {
position: sticky;
top: 0;
background: rgba(15, 23, 42, .98);
text-align: left;
padding: 10px 8px;
color: #cbd5e1;
white-space: nowrap;
z-index: 2;
}
.sheet td[contenteditable="true"] {
min-width: 120px;
padding: 8px 8px;
outline: none;
white-space: pre-wrap;
word-break: break-word;
}
.sheet td[contenteditable="true"]:focus {
background: rgba(56,189,248,.12);
box-shadow: inset 0 0 0 1px rgba(56,189,248,.35);
}
.sheet td.rownum, .sheet th.rownum {
min-width: 54px;
width: 54px;
text-align: right;
color: var(--muted);
padding-right: 10px;
background: rgba(15, 23, 42, .85);
position: sticky;
left: 0;
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;
gap: 10px;
grid-template-columns: 1fr;
}
textarea {
width: 100%;
min-height: 120px;
resize: vertical;
background: rgba(3, 7, 18, .45);
color: var(--text);
border: 1px solid rgba(255,255,255,.10);
border-radius: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.muted { color: var(--muted); }
.warn { color: var(--warn); }
.error { color: var(--danger); }
.tiny {
font-size: 11px;
color: var(--muted);
}
.chip {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,.06);
color: var(--muted);
font-size: 12px;
}
.toolbar {
display: flex;
gap: 10px;
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;
gap: 18px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.sheet tr.selected td {
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(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;
margin-top: 8px;
}
.diff-item {
padding: 10px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
background: rgba(3, 7, 18, .42);
font-size: 12px;
}
.diff-item strong { color: var(--text); }
.diff-item code {
display: block;
white-space: pre-wrap;
margin-top: 6px;
color: #cbd5e1;
}
.history-list {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 260px;
overflow: auto;
}
.history-item {
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
background: rgba(3, 7, 18, .42);
padding: 10px;
font-size: 12px;
}
.history-item code {
display: block;
margin-top: 6px;
white-space: pre-wrap;
color: #cbd5e1;
}
.inspector {
display: grid;
gap: 10px;
}
.inspector-grid {
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 12px;
}
.inspector-pre {
margin: 0;
white-space: pre-wrap;
max-height: 280px;
overflow: auto;
background: rgba(3,7,18,.45);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px;
}
.view-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.view-controls select, .view-controls input {
background: rgba(3, 7, 18, .45);
color: var(--text);
border: 1px solid rgba(255,255,255,.10);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
min-width: 120px;
}
.collection-grid {
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 12px;
}
.collection-panel {
display: grid;
gap: 10px;
}
.collection-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.collection-filter-row input {
background: rgba(3, 7, 18, .45);
color: var(--text);
border: 1px solid rgba(255,255,255,.10);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
min-width: 180px;
}
.collection-list {
display: grid;
gap: 8px;
margin-top: 8px;
}
.collection-item {
width: 100%;
text-align: left;
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px;
background: rgba(3, 7, 18, .42);
color: var(--text);
font-size: 12px;
cursor: pointer;
}
.collection-item.selected {
border-color: rgba(96, 165, 250, .8);
box-shadow: 0 0 0 1px rgba(96, 165, 250, .35) inset;
}
.collection-item code {
display: block;
margin-top: 6px;
white-space: pre-wrap;
color: #cbd5e1;
}
.collection-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.metric-card {
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px;
background: rgba(3, 7, 18, .42);
font-size: 12px;
}
.metric-card strong {
display: block;
font-size: 16px;
margin-top: 4px;
color: var(--text);
}
.compact-log {
margin: 0;
white-space: pre-wrap;
max-height: 220px;
overflow: auto;
background: rgba(3,7,18,.45);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px;
font-size: 12px;
}
.shortcut-hint {
color: var(--muted);
font-size: 11px;
margin-top: 8px;
}
.log-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.log-filter-row input {
background: rgba(3, 7, 18, .45);
color: var(--text);
border: 1px solid rgba(255,255,255,.10);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
min-width: 220px;
}
@media (max-width: 1080px) {
.two-col { grid-template-columns: 1fr; }
.inspector-grid { grid-template-columns: 1fr; }
.collection-grid { grid-template-columns: 1fr; }
.collection-metrics { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h1>Snapshot Admin</h1>
<div class="subline">SQLite canonical editor for <code>settings</code> and <code>account_snapshot</code>. Save via API only; xlsx stays as export surface.</div>
<div class="toolbar" style="margin-top:10px;">
<a class="btn" href="/collection">Open collection dashboard</a>
<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 snapshot-panel">
<div class="panel-head">
<h2>Workspace</h2>
<div class="actions">
<button class="primary" onclick="reloadState()">Reload</button>
<button class="good" onclick="seedWorkspace()">Seed from GatherTradingData.json</button>
<button onclick="downloadExport()">Download JSON</button>
<button class="danger" onclick="undoLastChange('settings')">Undo settings</button>
<button class="danger" onclick="undoLastChange('account_snapshot')">Undo snapshot</button>
<button class="good" onclick="runAutofix()">Apply safe autofix</button>
</div>
</div>
<div class="status" id="workspaceStatus">Loading...</div>
<div class="pane-body">
<div class="two-col">
<div>
<div class="tiny">Validation</div>
<pre id="validationBox" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
<div class="tiny" style="margin-top:10px;">Suggestions</div>
<pre id="suggestionBox" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
</div>
<div>
<div class="tiny">Diff preview</div>
<pre id="diffPreview" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Approval & Locks</h2>
<div class="actions">
<button onclick="lockDomain('settings')">Lock settings</button>
<button onclick="unlockDomain('settings')">Unlock settings</button>
<button onclick="lockDomain('account_snapshot')">Lock snapshot</button>
<button onclick="unlockDomain('account_snapshot')">Unlock snapshot</button>
<button class="good" onclick="approveDomain('settings')">Approve settings</button>
<button class="good" onclick="approveDomain('account_snapshot')">Approve snapshot</button>
</div>
</div>
<div class="pane-body">
<div class="two-col">
<div>
<div class="chip" id="approvalSettingsChip">settings approval</div>
<div class="chip" id="approvalSnapshotChip">snapshot approval</div>
<div class="muted" id="lockSummary" style="margin-top:10px;"></div>
<div class="muted" id="approvalRowSummary" style="margin-top:8px;"></div>
<div class="muted" id="historySummary" style="margin-top:8px;"></div>
<div class="toolbar" style="margin-top:12px;">
<input id="lockDomainInput" value="settings" aria-label="lock domain" />
<input id="lockTargetInput" placeholder="target_ref (key or ticker)" aria-label="lock target" />
<button onclick="lockTargetFromInput()">Lock target</button>
<button onclick="unlockTargetFromInput()">Unlock target</button>
<button class="good" onclick="approvePendingChanges()">Approve pending</button>
<button onclick="previewDiff()">Refresh diff</button>
<button class="primary" onclick="exportApprovalPacket()">Export approval packet</button>
</div>
</div>
<div>
<div class="muted">Recent change log</div>
<div class="log-filter-row">
<input id="changeLogFilter" placeholder="Filter change log" oninput="renderMeta()" />
<button onclick="clearChangeLogFilter()">Clear filter</button>
</div>
<pre id="changeLog" style="margin:10px 0 0; white-space:pre-wrap; max-height:240px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
<div class="muted" style="margin-top:10px;">Timeline</div>
<div id="changeTimeline" class="history-list"></div>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>KIS Collection</h2>
<div class="actions">
<button class="primary" onclick="reloadState()">Refresh collection</button>
<button onclick="copyCollectionStatus()">Copy status</button>
</div>
</div>
<div class="pane-body collection-panel">
<div class="collection-grid">
<div>
<div class="chip" id="collectionChip">collection: loading...</div>
<div class="muted" id="collectionSummary" style="margin-top:10px;"></div>
<div class="collection-metrics" id="collectionMetrics"></div>
<div class="collection-filter-row">
<input id="collectionFilter" placeholder="Filter runs / snapshots / errors" oninput="applyCollectionFilter()" />
<button onclick="clearCollectionFilter()">Clear filter</button>
</div>
</div>
<div>
<div class="muted">Recent collector runs</div>
<div id="collectionRuns" class="collection-list"></div>
<div class="muted" style="margin-top:10px;">Recent collector snapshots</div>
<div id="collectionSnapshots" class="collection-list"></div>
<div class="muted" style="margin-top:10px;">Recent collector errors</div>
<div id="collectionErrors" class="collection-list"></div>
<div class="muted" style="margin-top:10px;">Collection detail</div>
<pre id="collectionDetail" class="inspector-pre"></pre>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Selection Inspector</h2>
<div class="actions">
<button onclick="focusSelectedRow()">Focus row</button>
<button onclick="copySelectedTarget()">Copy target</button>
<button class="good" onclick="approveSelectedRow()">Approve selected</button>
<button onclick="lockSelectedRow()">Lock selected</button>
<button onclick="unlockSelectedRow()">Unlock selected</button>
</div>
</div>
<div class="pane-body inspector">
<div class="inspector-grid">
<div>
<div class="muted" id="selectedRowSummary">No row selected.</div>
<pre id="selectedRowDetail" class="inspector-pre"></pre>
<div class="tiny" style="margin-top:10px;">Recent row history</div>
<pre id="selectedRowHistory" class="inspector-pre"></pre>
</div>
<div>
<div class="muted">Batch paste</div>
<div class="view-controls">
<select id="batchDomainSelect" onchange="syncBatchDomain()">
<option value="settings">settings</option>
<option value="account_snapshot">account_snapshot</option>
</select>
<button onclick="applyBatchPaste()">Apply TSV to selection</button>
</div>
<textarea id="batchPasteTsv" placeholder="Paste TSV here to apply from selected row onward."></textarea>
<div class="tiny">Tip: clipboard paste still works directly in the grid. This panel is for multi-row batch edit against the selected row.</div>
<div class="shortcut-hint">Shortcuts: `Ctrl+S` save current domain, `Ctrl+Enter` save current domain, `Delete` remove selected row.</div>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<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>
</div>
</div>
<div class="pane-body">
<div class="view-controls">
<input id="settingsSortField" placeholder="sort field" list="settingsSortFields" oninput="applyViewPreferences('settings')" />
<select id="settingsSortDirection" onchange="applyViewPreferences('settings')">
<option value="asc">asc</option>
<option value="desc">desc</option>
</select>
<button onclick="saveViewPreset('settings')">Save view</button>
<select id="settingsViewPreset" onchange="loadViewPreset('settings')"></select>
<button onclick="clearViewPreset('settings')">Delete view</button>
</div>
<datalist id="settingsSortFields">
<option value="ordinal"></option>
<option value="key"></option>
<option value="value"></option>
<option value="note"></option>
<option value="updated_at"></option>
</datalist>
<div class="grid-wrap" id="settingsWrap"></div>
<div class="filter-row">
<input id="settingsFilter" placeholder="Filter settings key / note" oninput="applyFilters()" />
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<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>
</div>
</div>
<div class="status">
<span class="chip">Paste TSV below and replace all rows</span>
<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')">
<option value="asc">asc</option>
<option value="desc">desc</option>
</select>
<button onclick="saveViewPreset('account_snapshot')">Save view</button>
<select id="snapshotViewPreset" onchange="loadViewPreset('account_snapshot')"></select>
<button onclick="clearViewPreset('account_snapshot')">Delete view</button>
</div>
<datalist id="snapshotSortFields">
<option value="ordinal"></option>
<option value="ticker"></option>
<option value="name"></option>
<option value="account"></option>
<option value="account_type"></option>
<option value="parse_status"></option>
<option value="position_type"></option>
<option value="updated_at"></option>
<option value="market_value"></option>
</datalist>
<div class="filter-row">
<input id="snapshotFilter" placeholder="Filter ticker / name / account" oninput="applyFilters()" />
</div>
<div class="grid-wrap" id="snapshotWrap"></div>
<div class="note-box">
<textarea id="snapshotTsv" placeholder="Paste TSV rows here (headerless or with header)."></textarea>
<div class="toolbar">
<button class="good" onclick="importSnapshotTsv()">Import TSV (replace)</button>
<button onclick="fillSnapshotTemplate()">Insert blank template</button>
</div>
</div>
</div>
</section>
</div>
</main>
<script>
const state = {
settingsRows: [],
snapshotRows: [],
initialSettingsRows: [],
initialSnapshotRows: [],
snapshotColumns: [],
summary: {},
approvalRows: [],
approvalSettings: {},
approvalSnapshot: {},
locks: [],
recentChanges: [],
validation: { settings: [], account_snapshot: [] },
suggestions: [],
autofixActions: [],
filterSettings: "",
filterSnapshot: "",
filterCollection: "",
filterChangeLog: "",
selected: { domain: "", target_ref: "" },
viewPresets: { settings: [], account_snapshot: [] },
viewPrefs: {
settings: { sortField: "ordinal", sortDirection: "asc" },
account_snapshot: { sortField: "ordinal", sortDirection: "asc" },
},
collection: {},
collectionSelection: { kind: "", key: "" },
};
const VIEW_STORAGE_KEY = "snapshot_admin_view_presets_v1";
const PREF_STORAGE_KEY = "snapshot_admin_view_prefs_v1";
const settingsColumns = ["ordinal", "key", "value", "note", "updated_at"];
function esc(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function cellValue(row, column) {
const value = row[column];
if (value === undefined || value === null) return "";
if (typeof value === "object") return JSON.stringify(value, null, 2);
return String(value);
}
function collectTable(tableId, columns) {
const table = document.getElementById(tableId);
const rows = [];
const bodyRows = table.querySelectorAll("tbody tr[data-row-index]");
bodyRows.forEach((tr, index) => {
const row = { ordinal: index + 1, _row_ref: tr.dataset.rowRef || "" };
columns.forEach((col, colIndex) => {
const cell = tr.querySelector(`td[data-col-index="${colIndex}"]`);
row[col] = cell ? cell.innerText.trim() : "";
});
rows.push(row);
});
return rows;
}
function currentSettingsRows() {
try {
return collectTable("settingsTable", ["key", "value", "note"]).map((row, index) => ({
ordinal: index + 1,
_row_ref: row._row_ref || "",
key: row.key,
value: parseCellValue(row.value),
note: row.note,
}));
} catch (err) {
return state.settingsRows || [];
}
}
function currentSnapshotRows() {
try {
return collectTable("snapshotTable", state.snapshotColumns).map((row, index) => {
const normalized = { ordinal: index + 1, _row_ref: row._row_ref || "" };
state.snapshotColumns.forEach((column) => {
normalized[column] = parseCellValue(row[column]);
});
return normalized;
});
} catch (err) {
return state.snapshotRows || [];
}
}
function cloneRows(rows) {
return JSON.parse(JSON.stringify(rows || []));
}
function loadJsonStorage(key, fallback) {
try {
const text = localStorage.getItem(key);
if (!text) return fallback;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" ? parsed : fallback;
} catch (err) {
return fallback;
}
}
function saveJsonStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
return;
}
}
function rowKey(domain, row) {
if (domain === "settings") return String(row?._row_ref || row?.key || "").trim();
if (domain === "account_snapshot") return snapshotTargetRef(row);
return String(row?.target_ref || "").trim();
}
function snapshotTargetRef(row) {
const ref = String(row?._row_ref || "").trim();
if (ref) return ref;
const ordinal = String(row?._ordinal ?? row?.ordinal ?? "").trim();
if (ordinal) return `row:${ordinal}`;
const ticker = String(row?.ticker || "").trim();
if (ticker) return `ticker:${ticker}`;
return "row:unknown";
}
function settingsTargetRef(row) {
return String(row?._row_ref || row?.key || "").trim();
}
function rowDisplayLabel(domain, row) {
if (domain === "settings") {
return String(row?.key || "").trim() || "settings-row";
}
if (domain === "account_snapshot") {
const ticker = String(row?.ticker || "").trim();
const name = String(row?.name || "").trim();
const ordinal = String(row?._ordinal ?? row?.ordinal ?? "").trim();
return [ticker, name, ordinal ? `#${ordinal}` : ""].filter(Boolean).join(" ");
}
return rowKey(domain, row);
}
function collectionItemKey(kind, item, index) {
if (kind === "run") {
return String(item?.run_id || item?.started_at || index || "").trim();
}
if (kind === "snapshot") {
return [item?.run_id, item?.dataset_name, item?.ticker, item?.created_at, index].map((value) => String(value || "").trim()).join("|");
}
if (kind === "error") {
return [item?.run_id, item?.source_name, item?.ticker, item?.error_kind, item?.created_at, index].map((value) => String(value || "").trim()).join("|");
}
return String(item?.id || index || "").trim();
}
function collectionItemLabel(kind, item) {
if (kind === "run") {
const finished = item?.finished_at || "RUNNING";
return `${item?.started_at || ""} ${item?.status || ""} ${item?.collector_name || "collector"} ${item?.run_id || ""} -> ${finished}`.trim();
}
if (kind === "snapshot") {
return `${item?.created_at || ""} ${item?.dataset_name || ""}:${item?.ticker || ""} ${item?.source_status || ""}`.trim();
}
if (kind === "error") {
return `${item?.created_at || ""} ${item?.source_name || ""}:${item?.error_kind || ""} ${item?.ticker || ""} ${item?.error_message || ""}`.trim();
}
return JSON.stringify(item, null, 2);
}
function collectionItemDetail(kind, item) {
const base = item && typeof item === "object" ? item : {};
return JSON.stringify({ kind, ...base }, null, 2);
}
function selectCollectionItem(kind, key) {
state.collectionSelection = { kind, key };
renderCollectionState();
}
function currentCollectionSelectionKey(kind, item, index) {
return collectionItemKey(kind, item, index);
}
function selectedRowRef(domain) {
return state.selected.domain === domain ? String(state.selected.target_ref || "").trim() : "";
}
function selectRow(domain, row) {
state.selected = { domain, target_ref: rowKey(domain, row) };
renderSelectionInspector();
renderSettings();
renderSnapshot();
}
function currentSelectionRow() {
const domain = state.selected.domain;
const ref = String(state.selected.target_ref || "").trim();
if (!domain || !ref) return null;
const rows = domain === "settings" ? state.settingsRows : domain === "account_snapshot" ? state.snapshotRows : [];
return rows.find((row) => rowKey(domain, row) === ref) || null;
}
function currentSelectionTarget() {
const row = currentSelectionRow();
if (!row) return { domain: "", target_ref: "" };
return { domain: state.selected.domain, target_ref: rowKey(state.selected.domain, row) };
}
function selectedDomainOrDefault() {
if (state.selected.domain === "settings" || state.selected.domain === "account_snapshot") {
return state.selected.domain;
}
const active = document.activeElement;
if (active && active.closest) {
if (active.closest("#snapshotWrap") || active.closest("#snapshotTsv")) return "account_snapshot";
if (active.closest("#settingsWrap")) return "settings";
}
return "settings";
}
function normalizeViewPreset(domain, preset) {
const fallback = domain === "settings"
? { sortField: "ordinal", sortDirection: "asc", filter: "" }
: { sortField: "ordinal", sortDirection: "asc", filter: "" };
if (!preset || typeof preset !== "object") return fallback;
return {
sortField: String(preset.sortField || fallback.sortField),
sortDirection: String(preset.sortDirection || fallback.sortDirection),
filter: String(preset.filter || ""),
};
}
function loadStoredViewPrefs() {
const prefs = loadJsonStorage(PREF_STORAGE_KEY, {});
state.viewPrefs.settings = normalizeViewPreset("settings", prefs.settings);
state.viewPrefs.account_snapshot = normalizeViewPreset("account_snapshot", prefs.account_snapshot);
state.filterSettings = state.viewPrefs.settings.filter || "";
state.filterSnapshot = state.viewPrefs.account_snapshot.filter || "";
}
function persistViewPrefs() {
saveJsonStorage(PREF_STORAGE_KEY, state.viewPrefs);
}
function loadStoredPresets() {
const presets = loadJsonStorage(VIEW_STORAGE_KEY, { settings: [], account_snapshot: [] });
state.viewPresets.settings = Array.isArray(presets.settings) ? presets.settings : [];
state.viewPresets.account_snapshot = Array.isArray(presets.account_snapshot) ? presets.account_snapshot : [];
}
function persistViewPresets() {
saveJsonStorage(VIEW_STORAGE_KEY, state.viewPresets);
}
function updateViewControls(domain) {
const prefs = state.viewPrefs[domain] || {};
const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
document.getElementById(filterId).value = prefs.filter || "";
document.getElementById(sortFieldId).value = prefs.sortField || "ordinal";
document.getElementById(sortDirId).value = prefs.sortDirection || "asc";
const select = document.getElementById(presetId);
const presets = state.viewPresets[domain] || [];
select.innerHTML = ['<option value="">Saved views</option>']
.concat(presets.map((item) => `<option value="${esc(item.name)}">${esc(item.name)}</option>`))
.join("");
}
function applyViewPreferences(domain) {
const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
state.viewPrefs[domain] = {
sortField: document.getElementById(sortFieldId).value.trim() || "ordinal",
sortDirection: document.getElementById(sortDirId).value,
filter: document.getElementById(filterId).value,
};
persistViewPrefs();
renderSettings();
renderSnapshot();
}
function saveViewPreset(domain) {
const name = prompt("Save view preset name");
if (!name) return;
const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
const preset = {
name,
filter: document.getElementById(filterId).value,
sortField: document.getElementById(sortFieldId).value.trim() || "ordinal",
sortDirection: document.getElementById(sortDirId).value,
};
const list = state.viewPresets[domain] || [];
const index = list.findIndex((item) => item.name === name);
if (index >= 0) list[index] = preset;
else list.push(preset);
state.viewPresets[domain] = list;
persistViewPresets();
updateViewControls(domain);
}
function loadViewPreset(domain) {
const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
const name = document.getElementById(presetId).value;
if (!name) return;
const preset = (state.viewPresets[domain] || []).find((item) => item.name === name);
if (!preset) return;
const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
document.getElementById(filterId).value = preset.filter || "";
document.getElementById(sortFieldId).value = preset.sortField || "ordinal";
document.getElementById(sortDirId).value = preset.sortDirection || "asc";
applyViewPreferences(domain);
}
function clearViewPreset(domain) {
const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
const name = document.getElementById(presetId).value;
if (!name) return;
state.viewPresets[domain] = (state.viewPresets[domain] || []).filter((item) => item.name !== name);
persistViewPresets();
updateViewControls(domain);
}
function sortRows(rows, domain) {
const prefs = state.viewPrefs[domain] || {};
const field = prefs.sortField || "ordinal";
const direction = String(prefs.sortDirection || "asc").toLowerCase() === "desc" ? -1 : 1;
const sorted = (rows || []).slice();
sorted.sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
const aText = av === null || av === undefined ? "" : String(av);
const bText = bv === null || bv === undefined ? "" : String(bv);
const aNum = Number(aText);
const bNum = Number(bText);
if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aText !== "" && bText !== "") {
return (aNum - bNum) * direction;
}
return aText.localeCompare(bText, "ko") * direction;
});
return sorted;
}
function applyFiltersAndSort(domain, rows, columns) {
const filterText = domain === "settings" ? state.filterSettings : state.filterSnapshot;
const terms = String(filterText || "").toLowerCase().trim().replaceAll("\t", " ").split(" ").filter(Boolean);
const filtered = rows.filter((row) => matchesFilter(row, terms, columns));
return sortRows(filtered, domain);
}
function rowFieldDiffs(beforeRow, afterRow, columns) {
const fields = [];
const seen = new Set(columns || []);
for (const column of columns || []) {
const before = cellValue(beforeRow || {}, column);
const after = cellValue(afterRow || {}, column);
if (before !== after) {
fields.push({ field: column, before, after });
}
}
for (const key of Object.keys(beforeRow || {})) {
if (key.startsWith("_") || seen.has(key)) continue;
const before = cellValue(beforeRow || {}, key);
const after = cellValue(afterRow || {}, key);
if (before !== after) {
fields.push({ field: key, before, after });
}
}
for (const key of Object.keys(afterRow || {})) {
if (key.startsWith("_") || seen.has(key) || Object.prototype.hasOwnProperty.call(beforeRow || {}, key)) continue;
const before = "";
const after = cellValue(afterRow || {}, key);
if (before !== after) {
fields.push({ field: key, before, after });
}
}
return fields;
}
function rowDiffSummary(beforeRows, afterRows, domain, columns) {
const beforeMap = new Map((beforeRows || []).map((row) => [rowKey(domain, row), row]));
const afterMap = new Map((afterRows || []).map((row) => [rowKey(domain, row), row]));
const added = [];
const removed = [];
const changed = [];
for (const [key, afterRow] of afterMap.entries()) {
if (!beforeMap.has(key)) {
added.push({ key, row: afterRow });
continue;
}
const beforeRow = beforeMap.get(key);
const beforeJson = JSON.stringify(beforeRow);
const afterJson = JSON.stringify(afterRow);
if (beforeJson !== afterJson) {
changed.push({ key, before: beforeRow, after: afterRow, cells: rowFieldDiffs(beforeRow, afterRow, columns) });
}
}
for (const [key, beforeRow] of beforeMap.entries()) {
if (!afterMap.has(key)) {
removed.push({ key, row: beforeRow });
}
}
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);
const after = item.after ? JSON.stringify(item.after, null, 2) : JSON.stringify(item.row, null, 2);
const cellText = item.cells && item.cells.length
? `<code>${esc(item.cells.map((cell) => `${cell.field}: ${cell.before} -> ${cell.after}`).join("\n"))}</code>`
: "";
const detail = item.after
? `<strong>${esc(item.key)}</strong>${cellText}<code>before:\n${esc(before)}\n\nafter:\n${esc(after)}</code>`
: `<strong>${esc(item.key)}</strong><code>${esc(after)}</code>`;
return `<div class="diff-item"><span class="chip">${section.label}</span>${detail}</div>`;
}).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>${summaryLine}${blocks}</div>`;
}
function buildTable(containerId, tableId, columns, rows, options) {
const container = document.getElementById(containerId);
const domain = options?.domain || "";
const table = document.createElement("table");
table.className = "sheet";
table.id = tableId;
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const numHead = document.createElement("th");
numHead.className = "rownum";
numHead.textContent = "#";
headRow.appendChild(numHead);
columns.forEach((column) => {
const th = document.createElement("th");
th.textContent = column;
headRow.appendChild(th);
});
if (options && Array.isArray(options.rowActions) && options.rowActions.length > 0) {
const th = document.createElement("th");
th.textContent = "actions";
headRow.appendChild(th);
}
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach((row, rowIndex) => {
const tr = document.createElement("tr");
tr.dataset.rowIndex = String(rowIndex);
tr.dataset.rowRef = String(row?._row_ref || rowKey(domain, row) || "");
const currentSelection = state.selected || {};
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;
if (domain) {
selectRow(domain, row);
}
});
const rowNum = document.createElement("td");
rowNum.className = "rownum";
rowNum.textContent = String(rowIndex + 1);
tr.appendChild(rowNum);
columns.forEach((column, colIndex) => {
const td = document.createElement("td");
td.setAttribute("contenteditable", "true");
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", () => {
if (domain) selectRow(domain, row);
});
td.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
td.blur();
}
});
tr.appendChild(td);
});
if (options && Array.isArray(options.rowActions) && options.rowActions.length > 0) {
const actionTd = document.createElement("td");
const actions = document.createElement("div");
actions.className = "row-actions";
options.rowActions.forEach((action) => {
const btn = document.createElement("button");
btn.textContent = action.label;
if (action.className) {
btn.className = action.className;
}
btn.addEventListener("click", () => action.onClick(row, rowIndex));
actions.appendChild(btn);
});
actionTd.appendChild(actions);
tr.appendChild(actionTd);
}
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.innerHTML = "";
container.appendChild(table);
if (options && options.emptyMessage && rows.length === 0) {
const empty = document.createElement("div");
empty.className = "muted";
empty.style.padding = "12px";
empty.textContent = options.emptyMessage;
container.appendChild(empty);
}
}
function matchesFilter(row, terms, columns) {
if (!terms.length) return true;
const haystack = columns.map((column) => String(row[column] ?? "")).join(" ").toLowerCase();
return terms.every((term) => haystack.includes(term));
}
function handlePaste(event, tableId, columns) {
const text = event.clipboardData.getData("text/plain");
if (!text.includes("\t") && !text.includes("\n")) {
return;
}
event.preventDefault();
const table = document.getElementById(tableId);
const startCell = event.target.closest("td");
const startRow = startCell.parentElement.rowIndex - 1;
const startCol = Number(startCell.dataset.colIndex);
const rows = table.querySelectorAll("tbody tr[data-row-index]");
const pastedRows = text.replace(/\r/g, "").split("\n").filter((line) => line.trim() !== "");
pastedRows.forEach((line, rowOffset) => {
const targetRow = rows[startRow + rowOffset];
if (!targetRow) return;
const values = line.split("\t");
values.forEach((value, colOffset) => {
const targetCell = targetRow.querySelector(`td[data-col-index="${startCol + colOffset}"]`);
if (targetCell) {
targetCell.innerText = value;
}
});
});
}
function addSettingRow() {
state.settingsRows.push({ ordinal: state.settingsRows.length + 1, _row_ref: `settings:new:${state.settingsRows.length + 1}`, key: "", value: "", note: "", updated_at: "" });
renderSettings();
}
function addSnapshotRow() {
const row = { ordinal: state.snapshotRows.length + 1, _row_ref: `account_snapshot:new:${state.snapshotRows.length + 1}` };
state.snapshotColumns.forEach((col) => {
row[col] = "";
});
row.parse_status = "NOT_PROVIDED";
row.user_confirmed = "N";
state.snapshotRows.push(row);
renderSnapshot();
}
function deleteSettingRow(row) {
const index = state.settingsRows.indexOf(row);
if (index >= 0) {
state.settingsRows.splice(index, 1);
state.settingsRows.forEach((item, idx) => { item.ordinal = idx + 1; });
renderSettings();
}
}
function deleteSnapshotRow(row) {
const index = state.snapshotRows.indexOf(row);
if (index >= 0) {
state.snapshotRows.splice(index, 1);
state.snapshotRows.forEach((item, idx) => { item.ordinal = idx + 1; });
renderSnapshot();
}
}
function _nextTempRowRef(domain, suffix) {
const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
return `${domain}:${suffix}:${rows.length + 1}:${Date.now().toString(36)}`;
}
function _reindexRows(rows) {
rows.forEach((item, idx) => {
item.ordinal = idx + 1;
});
}
function insertSettingRow(referenceRow, placement = "below", duplicate = false) {
const index = state.settingsRows.indexOf(referenceRow);
const insertAt = index < 0 ? state.settingsRows.length : (placement === "above" ? index : index + 1);
const template = duplicate ? referenceRow : {};
const row = {
ordinal: insertAt + 1,
_row_ref: _nextTempRowRef("settings", duplicate ? "copy" : "blank"),
key: duplicate ? "" : "",
value: duplicate ? template.value : "",
note: duplicate ? template.note : "",
updated_at: "",
};
state.settingsRows.splice(insertAt, 0, row);
_reindexRows(state.settingsRows);
selectRow("settings", row);
renderSettings();
}
function insertSnapshotRow(referenceRow, placement = "below", duplicate = false) {
const index = state.snapshotRows.indexOf(referenceRow);
const insertAt = index < 0 ? state.snapshotRows.length : (placement === "above" ? index : index + 1);
const row = duplicate
? { ...cloneRows([referenceRow])[0] }
: {};
const nextRow = {
ordinal: insertAt + 1,
_row_ref: _nextTempRowRef("account_snapshot", duplicate ? "copy" : "blank"),
};
state.snapshotColumns.forEach((col) => {
if (duplicate) {
nextRow[col] = row[col] ?? "";
} else {
nextRow[col] = "";
}
});
nextRow.parse_status = duplicate ? (row.parse_status || "CAPTURE_READ_OK") : "NOT_PROVIDED";
nextRow.user_confirmed = duplicate ? (row.user_confirmed || "N") : "N";
state.snapshotRows.splice(insertAt, 0, nextRow);
_reindexRows(state.snapshotRows);
selectRow("account_snapshot", nextRow);
renderSnapshot();
}
function renderSettings() {
updateViewControls("settings");
const filteredRows = applyFiltersAndSort("settings", state.settingsRows, ["key", "value", "note"]);
buildTable("settingsWrap", "settingsTable", ["key", "value", "note"], filteredRows, {
domain: "settings",
emptyMessage: "No settings rows yet.",
rowActions: [
{ label: "Insert above", onClick: (row) => insertSettingRow(row, "above") },
{ label: "Insert below", onClick: (row) => insertSettingRow(row, "below") },
{ label: "Duplicate", onClick: (row) => insertSettingRow(row, "below", true) },
{ label: "Approve row", className: "good", onClick: (row) => approveTarget("settings", settingsTargetRef(row)) },
{ label: "Lock row", onClick: (row) => lockTarget("settings", settingsTargetRef(row)) },
{ label: "Unlock row", onClick: (row) => unlockTarget("settings", settingsTargetRef(row)) },
{ label: "Delete", className: "danger", onClick: deleteSettingRow },
],
});
}
function renderSnapshot() {
updateViewControls("account_snapshot");
const filteredRows = applyFiltersAndSort("account_snapshot", state.snapshotRows, ["ticker", "name", "account", "account_type", "parse_status", "position_type"]);
buildTable("snapshotWrap", "snapshotTable", state.snapshotColumns, filteredRows, {
domain: "account_snapshot",
emptyMessage: "No account snapshot rows yet.",
rowActions: [
{ label: "Insert above", onClick: (row) => insertSnapshotRow(row, "above") },
{ label: "Insert below", onClick: (row) => insertSnapshotRow(row, "below") },
{ label: "Duplicate", onClick: (row) => insertSnapshotRow(row, "below", true) },
{ label: "Approve row", className: "good", onClick: (row) => approveTarget("account_snapshot", snapshotTargetRef(row)) },
{ label: "Lock row", onClick: (row) => lockTarget("account_snapshot", snapshotTargetRef(row)) },
{ label: "Unlock row", onClick: (row) => unlockTarget("account_snapshot", snapshotTargetRef(row)) },
{ label: "Delete", className: "danger", onClick: deleteSnapshotRow },
],
});
}
function parseCellValue(value) {
const text = value.trim();
if (text === "") return "";
if (text === "null" || text === "None") return null;
if (text === "true") return true;
if (text === "false") return false;
const numericPattern = new RegExp("^-?(0|[1-9]\\d*)(\\.\\d+)?([eE][-+]?\\d+)?$");
if (numericPattern.test(text)) return Number(text);
try {
return JSON.parse(text);
} catch (err) {
return text;
}
}
async function reloadState() {
const response = await fetch("/api/state");
const payload = await response.json();
state.summary = payload.summary || {};
state.version = payload.version || {};
state.approvalRows = payload.approval_rows || [];
state.approvalSettings = payload.approval_settings || {};
state.approvalSnapshot = payload.approval_account_snapshot || {};
state.locks = payload.locks || [];
state.recentChanges = payload.recent_changes || [];
state.settingsRows = (payload.settings_rows || []).map((row) => ({ ...row, _row_ref: settingsTargetRef(row) }));
state.snapshotRows = (payload.account_snapshot_rows || []).map((row) => ({ ...row, _row_ref: snapshotTargetRef(row) }));
state.initialSettingsRows = cloneRows(state.settingsRows);
state.initialSnapshotRows = cloneRows(state.snapshotRows);
state.snapshotColumns = payload.account_snapshot_columns || [];
state.validation = payload.validation || { settings: [], account_snapshot: [] };
state.suggestions = (state.validation && state.validation.suggestions) || [];
state.autofixActions = payload.autofix_actions || [];
loadStoredViewPrefs();
loadStoredPresets();
renderSettings();
renderSnapshot();
renderMeta();
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)}
<strong>account_snapshot:</strong> ${esc(state.summary.account_snapshot_rows ?? 0)}
<strong>updated:</strong> ${esc(state.summary.latest_update || "")}
`;
previewDiff();
}
async function saveSettings() {
if (String(state.filterSettings || "").trim()) {
throw new Error("clear settings filter before saving");
}
const rows = currentSettingsRows();
if (state.locks.some((lock) => lock.domain === "settings" && lock.target_ref === "*")) {
throw new Error("settings are locked");
}
const targets = rows.map((row) => String(row.key || "").trim()).filter(Boolean);
if (state.locks.some((lock) => lock.domain === "settings" && lock.target_ref !== "*" && targets.includes(String(lock.target_ref || "").trim()))) {
throw new Error("one or more settings rows are locked");
}
const response = await fetch("/api/settings/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "settings save failed");
await reloadState();
alert(`settings saved: ${payload.settings_rows}`);
}
async function saveSnapshot() {
if (String(state.filterSnapshot || "").trim()) {
throw new Error("clear snapshot filter before saving");
}
const columns = state.snapshotColumns;
const rows = currentSnapshotRows();
if (state.locks.some((lock) => lock.domain === "account_snapshot" && lock.target_ref === "*")) {
throw new Error("account_snapshot is locked");
}
const targets = rows.map((row) => snapshotTargetRef(row)).filter(Boolean);
if (state.locks.some((lock) => lock.domain === "account_snapshot" && lock.target_ref !== "*" && targets.includes(String(lock.target_ref || "").trim()))) {
throw new Error("one or more account_snapshot rows are locked");
}
const response = await fetch("/api/account_snapshot/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "snapshot save failed");
await reloadState();
alert(`account_snapshot saved: ${payload.account_snapshot_rows}`);
}
async function importSnapshotTsv() {
const tsv = document.getElementById("snapshotTsv").value;
const response = await fetch("/api/account_snapshot/import_tsv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tsv }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "TSV import failed");
await reloadState();
alert(`Imported ${payload.account_snapshot_rows} snapshot rows`);
}
async function approveDomain(domain) {
const response = await fetch("/api/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "approve failed");
await reloadState();
}
async function approveTarget(domain, targetRef) {
const response = await fetch("/api/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "approve failed");
await reloadState();
return payload;
}
async function lockDomain(domain) {
return lockTarget(domain, "*");
}
async function lockTarget(domain, targetRef) {
const response = await fetch("/api/lock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "lock failed");
await reloadState();
}
async function unlockDomain(domain) {
return unlockTarget(domain, "*");
}
async function unlockTarget(domain, targetRef) {
const response = await fetch("/api/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "unlock failed");
await reloadState();
}
async function lockTargetFromInput() {
const domain = document.getElementById("lockDomainInput").value.trim();
const targetRef = document.getElementById("lockTargetInput").value.trim();
await lockTarget(domain, targetRef || "*");
}
async function unlockTargetFromInput() {
const domain = document.getElementById("lockDomainInput").value.trim();
const targetRef = document.getElementById("lockTargetInput").value.trim();
await unlockTarget(domain, targetRef || "*");
}
async function approvePendingChanges() {
const packet = buildApprovalPacket();
if (!packet.pending_targets.length) {
alert("No pending changes to approve.");
return;
}
for (const target of packet.pending_targets) {
const response = await fetch("/api/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: target.domain, target_ref: target.target_ref }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || `approve failed: ${target.domain}:${target.target_ref}`);
}
await reloadState();
}
function previewDiff() {
const settingsDiff = rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]);
const snapshotDiff = rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns);
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() {
const settingsDiff = rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]);
const snapshotDiff = rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns);
const pending_targets = [
...settingsDiff.added.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "added" })),
...settingsDiff.removed.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "removed" })),
...settingsDiff.changed.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "changed" })),
...snapshotDiff.added.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "added" })),
...snapshotDiff.removed.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "removed" })),
...snapshotDiff.changed.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "changed" })),
];
return {
formula_id: "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1",
generated_at: new Date().toISOString(),
summary: {
settings_changed: settingsDiff.added.length + settingsDiff.removed.length + settingsDiff.changed.length,
account_snapshot_changed: snapshotDiff.added.length + snapshotDiff.removed.length + snapshotDiff.changed.length,
pending_target_count: pending_targets.length,
},
pending_targets,
diff_preview: {
settings: settingsDiff,
account_snapshot: compressApprovalDiff(snapshotDiff),
},
approvals: state.approvalRows || [],
locks: state.locks || [],
workspace: state.summary || {},
};
}
async function exportApprovalPacket() {
const packet = buildApprovalPacket();
const response = await fetch("/api/approval_packet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packet }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "approval packet export failed");
alert(`approval packet exported: ${payload.packet_path}`);
await reloadState();
}
async function undoLastChange(domain) {
const response = await fetch("/api/undo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "undo failed");
await reloadState();
}
async function runAutofix() {
const eligible = (state.autofixActions || []).filter((item) => item.action_id !== "required_total_asset_missing");
if (!eligible.length) {
alert("No safe autofix actions available.");
return;
}
for (const action of eligible) {
const response = await fetch("/api/autofix", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action_id: action.action_id }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || `autofix failed: ${action.action_id}`);
}
await reloadState();
}
async function seedWorkspace() {
const response = await fetch("/api/bootstrap", { method: "POST" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "seed failed");
await reloadState();
alert("Workspace seeded from GatherTradingData.json");
}
async function downloadExport() {
const response = await fetch("/api/export");
const payload = await response.text();
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "snapshot_admin_export.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function fillSnapshotTemplate() {
document.getElementById("snapshotTsv").value = [
state.snapshotColumns.join("\t"),
state.snapshotColumns.map(() => "").join("\t"),
].join("\n");
}
function renderMeta() {
const settingsApproval = state.approvalSettings || {};
const snapshotApproval = state.approvalSnapshot || {};
const version = state.version || {};
const git = version.git || {};
const source = version.source || {};
document.getElementById("versionSummary").textContent =
`app=${version.app || "N/A"} | git=${git.commit || "N/A"} | tree=${git.tree_state || "N/A"} | source=${source.fingerprint || "N/A"}`;
document.getElementById("approvalSettingsChip").textContent =
`settings: ${settingsApproval.status || "MISSING"} / ${settingsApproval.updated_at || ""}`;
document.getElementById("approvalSnapshotChip").textContent =
`account_snapshot: ${snapshotApproval.status || "MISSING"} / ${snapshotApproval.updated_at || ""}`;
const locks = state.locks || [];
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 || [];
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();
state.filterChangeLog = changeLogFilter;
const visibleChanges = (state.recentChanges || []).filter((item) => {
if (!changeLogFilter) return true;
const haystack = [
item?.domain,
item?.action,
item?.target_ref,
item?.actor,
item?.note,
item?.created_at,
].map((value) => String(value || "").toLowerCase()).join(" ");
return haystack.includes(changeLogFilter);
});
document.getElementById("historySummary").textContent =
`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;
return `#${item.id} ${item.domain} ${item.action} ${item.target_ref} ${item.created_at}\n${beforeCount} -> ${afterCount}\n`;
}).join("\n");
const timelineEl = document.getElementById("changeTimeline");
if (timelineEl) {
timelineEl.innerHTML = visibleChanges.length
? visibleChanges.map((item) => {
const before = Array.isArray(item.before_json) ? item.before_json : [];
const after = Array.isArray(item.after_json) ? item.after_json : [];
return `
<details class="history-item">
<summary class="chip">${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)}</summary>
<code>${esc(JSON.stringify({ before, after }, null, 2))}</code>
</details>
`;
}).join("")
: "<div class='muted'>No timeline entries.</div>";
}
}
function renderValidation() {
const settingsErrors = state.validation?.settings || [];
const snapshotErrors = state.validation?.account_snapshot || [];
const suggestions = state.validation?.suggestions || [];
const settingsText = settingsErrors.length ? settingsErrors.map((item) => `- ${item}`).join("\n") : "settings OK";
const snapshotText = snapshotErrors.length ? snapshotErrors.map((item) => `- ${item}`).join("\n") : "account_snapshot OK";
const suggestionText = suggestions.length ? suggestions.map((item) => `- ${item}`).join("\n") : "No suggestions";
document.getElementById("validationBox").textContent = `settings\n${settingsText}\n\naccount_snapshot\n${snapshotText}`;
const autofixText = (state.autofixActions || []).map((item) => `- ${item.label}`).join("\n") || "No safe autofix available";
document.getElementById("suggestionBox").textContent = `Suggestions\n${suggestionText}\n\nSafe autofix\n${autofixText}`;
const diffSummary = `recent changes: ${(state.recentChanges || []).length}\napproval settings: ${(state.approvalSettings || {}).status || "MISSING"}\napproval snapshot: ${(state.approvalSnapshot || {}).status || "MISSING"}`;
if (!document.getElementById("diffPreview").innerHTML.trim()) {
document.getElementById("diffPreview").textContent = diffSummary;
}
}
function renderCollectionState() {
const collection = state.collection || {};
const counts = collection.counts || {};
const latestRun = collection.latest_run || {};
const latestReport = collection.latest_report || {};
const recentRuns = collection.runs || [];
const recentSnapshots = collection.recent_snapshots || [];
const recentErrors = collection.recent_errors || [];
const chip = document.getElementById("collectionChip");
const summary = document.getElementById("collectionSummary");
const metrics = document.getElementById("collectionMetrics");
const runsEl = document.getElementById("collectionRuns");
const snapshotsEl = document.getElementById("collectionSnapshots");
const errorsEl = document.getElementById("collectionErrors");
const detailEl = document.getElementById("collectionDetail");
if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !detailEl) return;
const filterText = String(document.getElementById("collectionFilter")?.value || state.filterCollection || "").trim().toLowerCase();
state.filterCollection = filterText;
chip.textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`;
summary.textContent = [
collection.db_path ? `db=${collection.db_path}` : "db=N/A",
collection.output_json_path ? `report=${collection.output_json_path}` : "report=N/A",
latestRun.run_id ? `latest_run=${latestRun.run_id}` : "",
latestReport.status ? `latest_status=${latestReport.status}` : "",
latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "",
filterText ? `filter=${filterText}` : "",
].filter(Boolean).join(" | ");
const sourceCounts = latestReport.source_counts || {};
const metricItems = [
{ label: "Runs", value: counts.collection_runs ?? 0 },
{ label: "Snapshots", value: counts.collection_snapshots ?? 0 },
{ label: "Errors", value: counts.collection_source_errors ?? 0 },
];
if (latestReport.row_count !== undefined) {
metricItems.push({ label: "Rows in report", value: latestReport.row_count });
}
if (Object.keys(sourceCounts).length) {
metricItems.push({ label: "KIS", value: sourceCounts.kis_open_api ?? 0 });
metricItems.push({ label: "Naver", value: sourceCounts.naver_finance ?? 0 });
metricItems.push({ label: "Seed", value: sourceCounts.gathertradingdata_json ?? 0 });
}
metrics.innerHTML = metricItems.map((item) => `<div class="metric-card">${esc(item.label)}<strong>${esc(item.value)}</strong></div>`).join("");
const selection = state.collectionSelection || {};
const matchesFilter = (kind, item) => {
if (!filterText) return true;
const haystack = JSON.stringify({ kind, ...item }).toLowerCase();
return haystack.includes(filterText);
};
const renderItemList = (kind, items, emptyText) => {
const visible = items
.map((item, index) => ({ item, index }))
.filter(({ item }) => matchesFilter(kind, item));
if (!visible.length) return `<div class="muted">${esc(filterText ? `No ${kind} items match filter.` : emptyText)}</div>`;
return visible.map(({ item, index }) => {
const key = currentCollectionSelectionKey(kind, item, index);
const selected = selection.kind === kind && selection.key === key;
return `
<button class="collection-item ${selected ? "selected" : ""}" onclick="selectCollectionItem(${JSON.stringify(kind)}, ${JSON.stringify(key)})">
${esc(collectionItemLabel(kind, item))}
<code>${esc(collectionItemDetail(kind, item))}</code>
</button>
`;
}).join("");
};
runsEl.innerHTML = renderItemList("run", recentRuns, "No collection runs yet.");
snapshotsEl.innerHTML = renderItemList("snapshot", recentSnapshots, "No collection snapshots yet.");
errorsEl.innerHTML = renderItemList("error", recentErrors, "No collection errors.");
const selectedItem = (() => {
const key = String(selection.key || "").trim();
if (!key) return null;
const run = recentRuns.find((item, index) => currentCollectionSelectionKey("run", item, index) === key);
if (run) return { kind: "run", item: run };
const snapshot = recentSnapshots.find((item, index) => currentCollectionSelectionKey("snapshot", item, index) === key);
if (snapshot) return { kind: "snapshot", item: snapshot };
const error = recentErrors.find((item, index) => currentCollectionSelectionKey("error", item, index) === key);
if (error) return { kind: "error", item: error };
return null;
})();
detailEl.textContent = selectedItem
? collectionItemDetail(selectedItem.kind, selectedItem.item)
: JSON.stringify({
latest_run: latestRun,
latest_report: latestReport,
}, null, 2);
}
async function copyCollectionStatus() {
const collection = state.collection || {};
const counts = collection.counts || {};
const latestReport = collection.latest_report || {};
const text = [
`db=${collection.db_path || "N/A"}`,
`runs=${counts.collection_runs ?? 0}`,
`snapshots=${counts.collection_snapshots ?? 0}`,
`errors=${counts.collection_source_errors ?? 0}`,
`status=${latestReport.status || "N/A"}`,
].join(" | ");
await navigator.clipboard.writeText(text);
}
function applyFilters() {
state.filterSettings = document.getElementById("settingsFilter").value;
state.filterSnapshot = document.getElementById("snapshotFilter").value;
state.viewPrefs.settings.filter = state.filterSettings;
state.viewPrefs.account_snapshot.filter = state.filterSnapshot;
persistViewPrefs();
renderSettings();
renderSnapshot();
}
function applyCollectionFilter() {
state.filterCollection = document.getElementById("collectionFilter").value;
renderCollectionState();
}
function clearCollectionFilter() {
const input = document.getElementById("collectionFilter");
if (input) input.value = "";
state.filterCollection = "";
renderCollectionState();
}
function clearChangeLogFilter() {
const input = document.getElementById("changeLogFilter");
if (input) input.value = "";
state.filterChangeLog = "";
renderMeta();
}
function renderSelectionInspector() {
const row = currentSelectionRow();
const summaryEl = document.getElementById("selectedRowSummary");
const detailEl = document.getElementById("selectedRowDetail");
const historyEl = document.getElementById("selectedRowHistory");
const batchDomainSelect = document.getElementById("batchDomainSelect");
if (!row) {
summaryEl.textContent = "No row selected.";
detailEl.textContent = "";
historyEl.textContent = "";
if (batchDomainSelect && !batchDomainSelect.value) {
batchDomainSelect.value = "settings";
}
return;
}
const domain = state.selected.domain;
const targetRef = rowKey(domain, row);
summaryEl.textContent = `${domain}:${targetRef} | ${rowDisplayLabel(domain, row)}`;
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();
return ref === "*" || ref === targetRef || ref === String(row?.ticker || "").trim();
});
historyEl.textContent = history.length
? history.slice(0, 8).map((item) => `#${item.id} ${item.action} ${item.target_ref} @ ${item.created_at}\n${JSON.stringify(item.before_json, null, 2)}\n=>\n${JSON.stringify(item.after_json, null, 2)}`).join("\n\n")
: "No recent history for selected row.";
if (batchDomainSelect) {
batchDomainSelect.value = domain;
}
}
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;
const selector = domain === "settings" ? "#settingsTable tbody tr" : "#snapshotTable tbody tr";
const targetRef = String(state.selected.target_ref || "");
const row = document.querySelector(`${selector}[data-row-ref="${CSS.escape(targetRef)}"]`);
if (row) {
row.scrollIntoView({ block: "center", behavior: "smooth" });
row.classList.add("selected");
}
}
async function copySelectedTarget() {
const target = currentSelectionTarget();
if (!target.domain) {
alert("No row selected.");
return;
}
await navigator.clipboard.writeText(`${target.domain}:${target.target_ref}`);
}
async function approveSelectedRow() {
const target = currentSelectionTarget();
if (!target.domain) {
alert("No row selected.");
return;
}
await approveTarget(target.domain, target.target_ref);
}
async function lockSelectedRow() {
const target = currentSelectionTarget();
if (!target.domain) {
alert("No row selected.");
return;
}
await lockTarget(target.domain, target.target_ref);
}
async function unlockSelectedRow() {
const target = currentSelectionTarget();
if (!target.domain) {
alert("No row selected.");
return;
}
await unlockTarget(target.domain, target.target_ref);
}
function syncBatchDomain() {
const domain = document.getElementById("batchDomainSelect").value;
const selected = currentSelectionTarget();
if (!selected.domain) return;
if (selected.domain !== domain) {
state.selected = { domain, target_ref: "" };
}
}
function _tsvRows(tsvText) {
return String(tsvText || "").replace(/\r/g, "").split("\n").filter((line) => line.trim() !== "");
}
function applyBatchPaste() {
const domain = document.getElementById("batchDomainSelect").value;
const tsv = document.getElementById("batchPasteTsv").value;
const lines = _tsvRows(tsv);
if (!lines.length) {
alert("Paste TSV first.");
return;
}
const selected = currentSelectionRow();
const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
if (!selected || state.selected.domain !== domain) {
alert("Select a row in the same domain first.");
return;
}
const tableId = domain === "settings" ? "settingsTable" : "snapshotTable";
const table = document.getElementById(tableId);
const bodyRows = Array.from(table.querySelectorAll("tbody tr[data-row-index]"));
const startIndex = bodyRows.findIndex((tr) => tr.dataset.rowRef === rowKey(domain, selected));
if (startIndex < 0) {
alert("Selected row no longer exists.");
return;
}
const columns = domain === "settings" ? ["key", "value", "note"] : state.snapshotColumns;
const pastedRows = lines.map((line) => line.split("\t"));
pastedRows.forEach((values, rowOffset) => {
const targetRow = bodyRows[startIndex + rowOffset];
if (!targetRow) return;
values.forEach((value, colIndex) => {
const cell = targetRow.querySelector(`td[data-col-index="${colIndex}"]`);
if (cell) cell.innerText = value;
});
});
if (domain === "settings") renderSettings(); else renderSnapshot();
renderSelectionInspector();
previewDiff();
}
async function saveSelectedDomain() {
const domain = selectedDomainOrDefault();
if (domain === "account_snapshot") {
await saveSnapshot();
} else {
await saveSettings();
}
}
function installKeyboardShortcuts() {
document.addEventListener("keydown", (event) => {
if (event.key === "F5") return;
const target = event.target;
const isTyping = target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
);
if (event.key === "Delete" && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (isTyping) return;
const row = currentSelectionRow();
if (!row) return;
const domain = state.selected.domain;
const confirmMsg = `${domain}:${rowKey(domain, row)} delete?`;
if (!confirm(confirmMsg)) return;
if (domain === "settings") {
deleteSettingRow(row);
} else if (domain === "account_snapshot") {
deleteSnapshotRow(row);
}
event.preventDefault();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
saveSelectedDomain().catch((err) => alert(err.message));
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
saveSelectedDomain().catch((err) => alert(err.message));
return;
}
if (isTyping) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const domain = selectedDomainOrDefault();
const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
if (!rows.length) return;
const current = currentSelectionRow();
let index = current ? rows.findIndex((row) => rowKey(domain, row) === rowKey(domain, current)) : -1;
index = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index <= 0 ? 0 : index - 1);
const next = rows[index];
if (next) {
selectRow(domain, next);
focusSelectedRow();
event.preventDefault();
}
}
});
}
reloadState().catch((error) => {
document.getElementById("workspaceStatus").innerHTML = `<span class="error">${esc(error.message)}</span>`;
});
installKeyboardShortcuts();
</script>
</body>
</html>
"""
def render_collection_html() -> str:
return """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KIS Collection Dashboard</title>
<style>
:root {
color-scheme: light;
--bg: #0f172a;
--panel: #111827;
--line: #243047;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #38bdf8;
--accent-2: #22c55e;
--chip: #172036;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: linear-gradient(180deg, #08101f 0%, #0b1220 100%); color: var(--text); }
header { padding: 24px; border-bottom: 1px solid rgba(255,255,255,.06); position: sticky; top: 0; background: rgba(5,10,20,.6); backdrop-filter: blur(10px); z-index: 10; }
h1 { margin: 0; font-size: 22px; }
.subline { margin-top: 6px; color: var(--muted); font-size: 13px; }
.wrap { max-width: 1280px; margin: 0 auto; padding: 20px 24px 40px; }
.panel { border: 1px solid rgba(255,255,255,.08); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95)); box-shadow: 0 16px 48px rgba(0,0,0,.24); }
.panel-head { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding: 16px 16px 10px; border-bottom: 1px solid rgba(255,255,255,.06); }
.actions { display:flex; flex-wrap:wrap; gap:8px; }
button, a.btn { border: 1px solid rgba(255,255,255,.12); background: var(--chip); color: var(--text); padding: 8px 12px; border-radius: 10px; cursor:pointer; font-size:13px; text-decoration:none; }
button.primary { background: linear-gradient(135deg, var(--accent), #0ea5e9); color:#fff; }
.pane { padding: 14px 16px 18px; }
.grid { display:grid; gap:12px; grid-template-columns: 1.1fr .9fr; }
.chip { display:inline-flex; gap:6px; align-items:center; padding:4px 8px; border-radius:999px; background: rgba(255,255,255,.06); color: var(--muted); font-size:12px; }
.muted { color: var(--muted); }
.metrics { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:8px; margin-top:10px; }
.metric { border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding: 10px; background: rgba(3,7,18,.42); font-size:12px; }
.metric strong { display:block; font-size:16px; margin-top:4px; }
.filter-row { display:flex; flex-wrap:wrap; gap:8px; margin-top: 10px; }
.filter-row input { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 10px; padding: 8px 10px; font-size: 12px; min-width: 220px; }
.list { display:grid; gap:8px; margin-top: 8px; }
.item { width:100%; text-align:left; border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px; background: rgba(3,7,18,.42); color: var(--text); font-size:12px; cursor:pointer; }
.item.selected { border-color: rgba(96,165,250,.8); box-shadow: 0 0 0 1px rgba(96,165,250,.35) inset; }
.item code, pre { white-space: pre-wrap; }
pre { margin:0; max-height: 320px; overflow:auto; background: rgba(3,7,18,.45); border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding:10px; font-size:12px; }
@media (max-width: 980px) { .grid, .metrics { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>KIS Collection Dashboard</h1>
<div class="subline">Separate read-only view for KIS collection run, snapshots, errors, and raw JSON evidence.</div>
</header>
<main class="wrap">
<section class="panel">
<div class="panel-head">
<div>
<div class="chip" id="collectionChip">collection: loading...</div>
<div class="muted" id="collectionSummary" style="margin-top:10px;"></div>
</div>
<div class="actions">
<a class="btn" href="/">Back to workspace</a>
<a class="btn" href="/tables">Open table browser</a>
<button class="primary" onclick="reloadState()">Refresh</button>
<button onclick="downloadRawCollection()">Download raw JSON</button>
<button onclick="downloadCsvCollection()">Download CSV</button>
</div>
</div>
<div class="pane">
<div class="grid">
<div>
<div class="metrics" id="collectionMetrics"></div>
<div class="filter-row">
<input id="collectionFilter" placeholder="Filter runs / snapshots / errors" oninput="renderCollectionState()" />
<input id="collectionTickerFilter" placeholder="Ticker quick search" oninput="renderCollectionState()" />
<input id="collectionDateFilter" placeholder="Date quick search" oninput="renderCollectionState()" />
<button onclick="clearFilter()">Clear filter</button>
</div>
<div class="muted" style="margin-top:12px;">Recent collector runs</div>
<div id="collectionRuns" class="list"></div>
<div class="muted" style="margin-top:12px;">Recent collector snapshots</div>
<div id="collectionSnapshots" class="list"></div>
<div class="muted" style="margin-top:12px;">Recent collector errors</div>
<div id="collectionErrors" class="list"></div>
</div>
<div>
<div class="muted">Collection detail</div>
<pre id="collectionDetail"></pre>
</div>
</div>
</div>
</section>
</main>
<script>
const state = { collection: {}, selection: { kind: "", key: "" } };
function esc(value) { return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); }
function keyFor(kind, item, index) {
if (kind === "run") return String(item?.run_id || item?.started_at || index || "").trim();
if (kind === "snapshot") return [item?.run_id, item?.dataset_name, item?.ticker, item?.created_at, index].join("|");
return [item?.run_id, item?.source_name, item?.ticker, item?.error_kind, item?.created_at, index].join("|");
}
function labelFor(kind, item) {
if (kind === "run") return `${item?.started_at || ""} ${item?.status || ""} ${item?.collector_name || "collector"} ${item?.run_id || ""} -> ${item?.finished_at || "RUNNING"}`.trim();
if (kind === "snapshot") return `${item?.created_at || ""} ${item?.dataset_name || ""}:${item?.ticker || ""} ${item?.source_status || ""}`.trim();
return `${item?.created_at || ""} ${item?.source_name || ""}:${item?.error_kind || ""} ${item?.ticker || ""} ${item?.error_message || ""}`.trim();
}
function detailFor(kind, item) { return JSON.stringify({ kind, ...item }, null, 2); }
function filtered(kind, items, filterText) {
const tickerText = String(document.getElementById("collectionTickerFilter")?.value || "").trim().toLowerCase();
const dateText = String(document.getElementById("collectionDateFilter")?.value || "").trim().toLowerCase();
return items.map((item, index) => ({ item, index })).filter(({ item }) => {
const haystack = JSON.stringify({ kind, ...item }).toLowerCase();
if (filterText && !haystack.includes(filterText)) return false;
if (tickerText && !haystack.includes(tickerText)) return false;
if (dateText && !haystack.includes(dateText)) return false;
return true;
});
}
async function reloadState() {
const response = await fetch("/api/state");
const payload = await response.json();
state.collection = payload.collection || {};
renderCollectionState();
}
function clearFilter() {
const input = document.getElementById("collectionFilter");
if (input) input.value = "";
const ticker = document.getElementById("collectionTickerFilter");
if (ticker) ticker.value = "";
const date = document.getElementById("collectionDateFilter");
if (date) date.value = "";
renderCollectionState();
}
function selectItem(kind, key) { state.selection = { kind, key }; renderCollectionState(); }
function renderCollectionState() {
const collection = state.collection || {};
const counts = collection.counts || {};
const latestRun = collection.latest_run || {};
const latestReport = collection.latest_report || {};
const runs = collection.runs || [];
const snapshots = collection.recent_snapshots || [];
const errors = collection.recent_errors || [];
const filterText = String(document.getElementById("collectionFilter")?.value || "").trim().toLowerCase();
document.getElementById("collectionChip").textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`;
document.getElementById("collectionSummary").textContent = [
collection.db_path ? `db=${collection.db_path}` : "db=N/A",
collection.output_json_path ? `report=${collection.output_json_path}` : "report=N/A",
latestRun.run_id ? `latest_run=${latestRun.run_id}` : "",
latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "",
filterText ? `filter=${filterText}` : "",
String(document.getElementById("collectionTickerFilter")?.value || "").trim() ? `ticker=${String(document.getElementById("collectionTickerFilter")?.value || "").trim()}` : "",
String(document.getElementById("collectionDateFilter")?.value || "").trim() ? `date=${String(document.getElementById("collectionDateFilter")?.value || "").trim()}` : "",
].filter(Boolean).join(" | ");
const sourceCounts = latestReport.source_counts || {};
const metrics = [
{ label: "Runs", value: counts.collection_runs ?? 0 },
{ label: "Snapshots", value: counts.collection_snapshots ?? 0 },
{ label: "Errors", value: counts.collection_source_errors ?? 0 },
];
if (latestReport.row_count !== undefined) metrics.push({ label: "Rows", value: latestReport.row_count });
if (Object.keys(sourceCounts).length) {
metrics.push({ label: "KIS", value: sourceCounts.kis_open_api ?? 0 });
metrics.push({ label: "Naver", value: sourceCounts.naver_finance ?? 0 });
metrics.push({ label: "Seed", value: sourceCounts.gathertradingdata_json ?? 0 });
}
const combinedCount = [...runs, ...snapshots, ...errors].length;
metrics.push({ label: "Visible", value: filtered("run", runs, filterText).length + filtered("snapshot", snapshots, filterText).length + filtered("error", errors, filterText).length });
document.getElementById("collectionMetrics").innerHTML = metrics.map((item) => `<div class="metric">${esc(item.label)}<strong>${esc(item.value)}</strong></div>`).join("");
const renderList = (kind, items, emptyText) => {
const visible = filtered(kind, items, filterText);
if (!visible.length) return `<div class="muted">${esc(filterText ? `No ${kind} items match filter.` : emptyText)}</div>`;
return visible.map(({ item, index }) => {
const key = keyFor(kind, item, index);
const selected = state.selection.kind === kind && state.selection.key === key;
return `<button class="item ${selected ? "selected" : ""}" onclick="selectItem(${JSON.stringify(kind)}, ${JSON.stringify(key)})">${esc(labelFor(kind, item))}<code>${esc(detailFor(kind, item))}</code></button>`;
}).join("");
};
document.getElementById("collectionRuns").innerHTML = renderList("run", runs, "No collection runs yet.");
document.getElementById("collectionSnapshots").innerHTML = renderList("snapshot", snapshots, "No collection snapshots yet.");
document.getElementById("collectionErrors").innerHTML = renderList("error", errors, "No collection errors.");
const key = String(state.selection.key || "").trim();
const selected = runs.find((item, index) => keyFor("run", item, index) === key)
|| snapshots.find((item, index) => keyFor("snapshot", item, index) === key)
|| errors.find((item, index) => keyFor("error", item, index) === key);
document.getElementById("collectionDetail").textContent = selected ? JSON.stringify(selected, null, 2) : JSON.stringify({ latest_run: latestRun, latest_report: latestReport }, null, 2);
}
async function downloadRawCollection() {
const response = await fetch("/api/state");
const payload = await response.json();
const blob = new Blob([JSON.stringify(payload.collection || {}, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "kis_collection_state.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function downloadCsvCollection() {
const response = await fetch("/api/state");
const payload = await response.json();
const collection = payload.collection || {};
const rows = [
...(collection.runs || []).map((item) => ({ kind: "run", ...item })),
...(collection.recent_snapshots || []).map((item) => ({ kind: "snapshot", ...item })),
...(collection.recent_errors || []).map((item) => ({ kind: "error", ...item })),
];
const headers = ["kind", "run_id", "collector_name", "started_at", "finished_at", "status", "dataset_name", "ticker", "name", "source_name", "error_kind", "error_message", "created_at"];
const csv = [headers.join(",")].concat(rows.map((row) => headers.map((header) => {
const value = row[header];
const text = String(value ?? "");
return `"${text.replaceAll('"', '""')}"`;
}).join(","))).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "kis_collection_state.csv";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
reloadState().catch((error) => { document.getElementById("collectionSummary").textContent = error.message; });
</script>
</body>
</html>
"""
def render_tables_html() -> str:
return """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snapshot Admin — Table Browser</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
</head>
<body>
<div class="page">
<header class="navbar navbar-expand-md navbar-dark d-print-none" data-bs-theme="dark">
<div class="container-xl">
<h1 class="navbar-brand">Snapshot Admin — Table Browser</h1>
<div class="navbar-nav flex-row">
<a class="btn btn-outline-light btn-sm me-2" href="/">Workspace editor</a>
<a class="btn btn-outline-light btn-sm" href="/collection">Collection dashboard</a>
</div>
</div>
</header>
<div class="page-wrapper">
<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="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>
<button class="btn btn-sm btn-primary" onclick="reload()">Refresh</button>
<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" style="table-layout:fixed;width:100%;">
<thead>
<tr id="gridHead"></tr>
<tr id="gridFilterRow"></tr>
</thead>
<tbody id="gridBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const state = { tables: [], current: "", limit: 50, offset: 0, total: 0, editable: false, rows: [], filter: "" };
function escapeHtml(value) {
if (value === null || value === undefined) return "";
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
return text.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));
}
function isEditableDomain(domain) {
return domain === "settings" || domain === "account_snapshot";
}
function normalizeCellValue(value) {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function editableCell(rowIndex, column, value) {
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.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();
}
function onTableChange() {
state.current = document.getElementById("tableSelect").value;
state.offset = 0;
clearGridFilters(false);
loadRows();
}
async function loadRows() {
if (!state.current) return;
const editable = state.tables.find(t => t.table === state.current)?.editable || false;
state.editable = editable;
const isDomain = isEditableDomain(state.current);
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 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) => {
return `<tr data-row-index="${rowIndex}">${displayColumns.map((c) => {
// Settings key and other primary columns can be protected or editable based on needs
const cellVal = row[c];
return editable ? editableCell(rowIndex, c, cellVal) : `<td>${escapeHtml(cellVal)}</td>`;
}).join("")}</tr>`;
})
.join("")
: `<tr><td colspan="99" class="text-secondary">no rows</td></tr>`;
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() {
state.offset = Math.max(0, state.offset - state.limit);
loadRows();
}
function nextPage() {
if (state.offset + state.limit < state.total) {
state.offset += state.limit;
loadRows();
}
}
function reload() {
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 || "");
const bodyRows = table.querySelectorAll("tbody tr[data-row-index]");
return Array.from(bodyRows).map((tr, index) => {
const cells = tr.querySelectorAll("td");
const row = {};
let cellIndex = 0;
for (const column of columns) {
if (String(column).startsWith("_")) {
continue;
}
const cell = cells[cellIndex++];
row[column] = cell ? cell.textContent : "";
}
row.ordinal = index + 1;
return row;
});
}
function parseCellValue(value) {
const text = String(value || "").trim();
if (text === "") return "";
if (text === "null" || text === "None") return null;
if (text === "true") return true;
if (text === "false") return false;
const numericPattern = new RegExp("^-?(0|[1-9]\\\\d*)(\\\\.\\\\d+)?([eE][-+]?\\\\d+)?$");
if (numericPattern.test(text)) return Number(text);
try {
return JSON.parse(text);
} catch (err) {
return text;
}
}
async function saveCurrentTable() {
if (!state.editable) return;
const isDomain = isEditableDomain(state.current);
const endpoint = isDomain
? (state.current === "settings" ? "/api/settings/save" : "/api/account_snapshot/save")
: "/api/table/save";
const rows = collectEditableRows().map(row => {
const parsedRow = {};
for (const k of Object.keys(row)) {
parsedRow[k] = parseCellValue(row[k]);
}
return parsedRow;
});
const payload = isDomain ? { rows } : { table: state.current, rows };
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "save failed");
await loadRows();
alert(`saved: ${state.current}`);
}
loadTables().catch((error) => {
document.getElementById("gridBody").innerHTML = `<tr><td class="text-danger">${escapeHtml(error.message)}</td></tr>`;
});
</script>
</body>
</html>
"""
class SnapshotAdminHandler(BaseHTTPRequestHandler):
db_path: Path = DEFAULT_DB
seed_json_path: Path = DEFAULT_SEED_JSON
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
def _handle_exception(self, exc: Exception) -> None:
_json_response(self, HTTPStatus.INTERNAL_SERVER_ERROR, {"detail": str(exc)})
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/":
_text_response(self, HTTPStatus.OK, render_index_html(), "text/html; charset=utf-8")
return
if parsed.path == "/collection":
_text_response(self, HTTPStatus.OK, render_collection_html(), "text/html; charset=utf-8")
return
if parsed.path == "/tables":
_text_response(self, HTTPStatus.OK, render_tables_html(), "text/html; charset=utf-8")
return
if parsed.path == "/api/tables":
_json_response(self, HTTPStatus.OK, {"tables": list_browsable_tables(self.db_path)})
return
if parsed.path == "/api/table_rows":
query = parse_qs(parsed.query)
table = (query.get("table") or [""])[0]
try:
limit = int((query.get("limit") or ["50"])[0])
offset = int((query.get("offset") or ["0"])[0])
except ValueError:
_json_response(self, HTTPStatus.BAD_REQUEST, {"detail": "limit/offset must be integers"})
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,
filter_text=filter_text,
column_filters=column_filters,
)
except ValueError as exc:
_json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
return
_json_response(self, HTTPStatus.OK, payload)
return
if parsed.path == "/api/state":
_json_response(self, HTTPStatus.OK, build_ui_state(self.db_path))
return
if parsed.path == "/api/history":
_json_response(
self,
HTTPStatus.OK,
{
"settings": load_change_log_rows(self.db_path, limit=25),
"approvals": load_approval_rows(self.db_path),
"locks": load_locks(self.db_path),
},
)
return
if parsed.path == "/api/export":
_text_response(
self,
HTTPStatus.OK,
json.dumps(export_payload(self.db_path), ensure_ascii=False, indent=2),
"application/json; charset=utf-8",
)
return
if parsed.path == "/favicon.ico":
_text_response(self, HTTPStatus.NO_CONTENT, "")
return
_json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
try:
if parsed.path == "/api/bootstrap":
summary = import_seed_json(self.db_path, self.seed_json_path)
_json_response(self, HTTPStatus.OK, summary)
return
payload = _read_json_body(self)
if parsed.path == "/api/settings/save":
if is_locked(self.db_path, "settings"):
raise ValueError("settings are locked")
rows = payload.get("rows")
if not isinstance(rows, list):
raise ValueError("rows must be a list")
normalized_rows = []
for idx, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
key = str(row.get("key") or "").strip()
if not key:
continue
normalized_rows.append(
{
"ordinal": idx,
"key": key,
"value": row.get("value", ""),
"note": str(row.get("note") or ""),
}
)
conflicts = lock_conflicts_for_rows(self.db_path, "settings", normalized_rows)
if conflicts:
refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
raise ValueError(f"settings lock conflict: {refs}")
with open_connection(self.db_path) as conn:
replace_settings(conn, normalized_rows)
_json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
return
if parsed.path == "/api/account_snapshot/save":
if is_locked(self.db_path, "account_snapshot"):
raise ValueError("account_snapshot is locked")
rows = payload.get("rows")
if not isinstance(rows, list):
raise ValueError("rows must be a list")
normalized_rows: list[dict[str, Any]] = []
for idx, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
candidate = {key: value for key, value in row.items() if not key.startswith("_")}
candidate["ordinal"] = idx
normalized_rows.append(candidate)
conflicts = lock_conflicts_for_rows(self.db_path, "account_snapshot", normalized_rows)
if conflicts:
refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
raise ValueError(f"account_snapshot lock conflict: {refs}")
with open_connection(self.db_path) as conn:
replace_account_snapshot(conn, normalized_rows)
_json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
return
if parsed.path == "/api/account_snapshot/import_tsv":
if is_locked(self.db_path, "account_snapshot"):
raise ValueError("account_snapshot is locked")
tsv_text = str(payload.get("tsv") or "")
rows = parse_account_snapshot_tsv(tsv_text)
with open_connection(self.db_path) as conn:
replace_account_snapshot(conn, rows)
_json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
return
if parsed.path == "/api/table/save":
table = str(payload.get("table") or "").strip()
rows = payload.get("rows")
if table not in EDITABLE_TABLES:
raise ValueError(f"table not editable: {table}")
if not isinstance(rows, list):
raise ValueError("rows must be a list")
db_path = _resolve_table_db(table, self.db_path)
if not db_path:
raise ValueError(f"database not found for table: {table}")
with open_connection(db_path) as conn:
conn.execute("BEGIN TRANSACTION")
try:
conn.execute(f"DELETE FROM {table}") # noqa: S608 - Whitelisted table name
if rows:
first_row = rows[0]
columns = [k for k in first_row.keys() if not k.startswith("_")]
if "rowid" in columns:
columns.remove("rowid")
if "_rowid" in columns:
columns.remove("_rowid")
placeholders = ", ".join(["?"] * len(columns))
col_list = ", ".join(columns)
insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})" # noqa: S608 - Whitelisted table name
for row in rows:
values = [row.get(col) for col in columns]
conn.execute(insert_sql, values)
conn.commit()
except Exception as e:
conn.rollback()
raise e
_json_response(self, HTTPStatus.OK, {"status": "SUCCESS", "table": table, "row_count": len(rows)})
return
if parsed.path == "/api/approval_packet":
packet = payload.get("packet")
if not isinstance(packet, dict):
raise ValueError("packet must be an object")
artifacts = write_approval_packet_artifacts(packet)
response = {
"gate": "PASS",
"packet_path": artifacts["json_path"],
"md_path": artifacts["md_path"],
"formula_id": packet.get("formula_id", "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1"),
}
_json_response(self, HTTPStatus.OK, response)
return
if parsed.path == "/api/approve":
domain = str(payload.get("domain") or "")
if domain not in {"settings", "account_snapshot"}:
raise ValueError("domain must be settings or account_snapshot")
target_ref = str(payload.get("target_ref") or "*")
with open_connection(self.db_path) as conn:
before = _approval_entry_from_conn(conn, domain, target_ref)
set_approval(conn, domain, "APPROVED", target_ref=target_ref, approved_by="ui", note="manual approval")
after = _approval_entry_from_conn(conn, domain, target_ref)
record_change_log(
conn,
domain=domain,
action="approve",
target_ref=target_ref,
before_json=before,
after_json=after,
actor="ui",
note="manual approval",
)
conn.commit()
_json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "APPROVED"})
return
if parsed.path == "/api/lock":
domain = str(payload.get("domain") or "")
target_ref = str(payload.get("target_ref") or "*")
if domain not in {"settings", "account_snapshot"}:
raise ValueError("domain must be settings or account_snapshot")
with open_connection(self.db_path) as conn:
before = _lock_entry_from_conn(conn, domain, target_ref)
set_lock(conn, domain, target_ref, locked_by="ui", reason="manual lock")
after = _lock_entry_from_conn(conn, domain, target_ref)
record_change_log(
conn,
domain=domain,
action="lock",
target_ref=target_ref,
before_json=before,
after_json=after,
actor="ui",
note="manual lock",
)
conn.commit()
_json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "LOCKED"})
return
if parsed.path == "/api/unlock":
domain = str(payload.get("domain") or "")
target_ref = str(payload.get("target_ref") or "*")
if domain not in {"settings", "account_snapshot"}:
raise ValueError("domain must be settings or account_snapshot")
with open_connection(self.db_path) as conn:
before = _lock_entry_from_conn(conn, domain, target_ref)
clear_lock(conn, domain, target_ref)
after = _lock_entry_from_conn(conn, domain, target_ref)
record_change_log(
conn,
domain=domain,
action="unlock",
target_ref=target_ref,
before_json=before,
after_json=after,
actor="ui",
note="manual unlock",
)
conn.commit()
_json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "UNLOCKED"})
return
if parsed.path == "/api/undo":
domain = str(payload.get("domain") or "")
if domain not in {"settings", "account_snapshot"}:
raise ValueError("domain must be settings or account_snapshot")
if is_locked(self.db_path, domain):
raise ValueError(f"{domain} is locked")
with open_connection(self.db_path) as conn:
result = undo_last_change(conn, domain, actor="ui")
_json_response(self, HTTPStatus.OK, result if result else {"domain": domain, "status": "UNDONE"})
return
if parsed.path == "/api/autofix":
action_id = str(payload.get("action_id") or "")
if not action_id:
raise ValueError("action_id required")
with open_connection(self.db_path) as conn:
result = apply_safe_autofix_action(conn, action_id, actor="ui")
_json_response(self, HTTPStatus.OK, result)
return
_json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})
except Exception as exc: # noqa: BLE001
self._handle_exception(exc)
def serve(host: str, port: int, db_path: Path | str | None = None, seed_json_path: Path | str | None = None, bootstrap: bool = True) -> None:
db = normalize_db_path(db_path)
seed = Path(seed_json_path) if seed_json_path else DEFAULT_SEED_JSON
if bootstrap and seed.exists():
with open_connection(db) as conn:
from .snapshot_admin_store_v1 import ensure_schema
ensure_schema(conn)
if summarize_workspace(db)["settings_rows"] == 0 and summarize_workspace(db)["account_snapshot_rows"] == 0:
import_seed_json(db, seed)
SnapshotAdminHandler.db_path = db
SnapshotAdminHandler.seed_json_path = seed
server = ThreadingHTTPServer((host, port), SnapshotAdminHandler)
print(f"Snapshot Admin listening on http://{host}:{port}")
print(f"SQLite DB: {db}")
print(f"Seed JSON: {seed}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def main() -> int:
parser = argparse.ArgumentParser(description="Run the snapshot admin web server.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--db", type=Path, default=DEFAULT_DB)
parser.add_argument("--seed", type=Path, default=DEFAULT_SEED_JSON)
parser.add_argument("--no-bootstrap", action="store_true")
args = parser.parse_args()
serve(args.host, args.port, args.db, args.seed, bootstrap=not args.no_bootstrap)
return 0
if __name__ == "__main__":
raise SystemExit(main())