3636 lines
156 KiB
Python
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
}
|
|
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">"); }
|
|
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()">« Prev</button>
|
|
<span class="d-flex align-items-center px-2" id="pageInfo"></span>
|
|
<button class="btn btn-sm" onclick="nextPage()">Next »</button>
|
|
<button class="btn btn-sm 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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())
|