From 532924e218b3cf63eb2a1f4777b697a635d311a1 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Wed, 24 Jun 2026 18:00:37 +0900 Subject: [PATCH] =?UTF-8?q?style(web):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20UI/UX=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20=EB=B0=80=EB=8F=84?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/quant_engine/snapshot_admin_server_v1.py | 1999 +++++++++++++++--- 1 file changed, 1718 insertions(+), 281 deletions(-) diff --git a/src/quant_engine/snapshot_admin_server_v1.py b/src/quant_engine/snapshot_admin_server_v1.py index 1bea589..d2b397d 100644 --- a/src/quant_engine/snapshot_admin_server_v1.py +++ b/src/quant_engine/snapshot_admin_server_v1.py @@ -1,9 +1,12 @@ from __future__ import annotations import argparse +import datetime as dt import json import sqlite3 import subprocess +import time +import sys from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -15,6 +18,8 @@ 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" +WORKBOOK_JSON = ROOT / "GatherTradingData.json" +WORKBOOK_XLSX = ROOT / "GatherTradingData.xlsx" QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db" # WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은 @@ -285,9 +290,15 @@ def _source_fingerprint() -> dict[str, Any]: latest_mtime = max(latest_mtime, path.stat().st_mtime) except OSError: continue + latest_updated_at = "" + if latest_mtime: + latest_updated_at = dt.datetime.fromtimestamp(latest_mtime, tz=dt.timezone.utc).astimezone( + dt.timezone(dt.timedelta(hours=9)) + ).isoformat() return { "fingerprint": digest.hexdigest()[:16], "latest_mtime": latest_mtime, + "latest_updated_at": latest_updated_at, } @@ -331,6 +342,7 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT) except Exception: collection = {} + workbook_registry = load_workbook_sheet_registry() return { "version": { "app": SNAPSHOT_ADMIN_VERSION, @@ -358,10 +370,246 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: }, "autofix_actions": autofix_actions, "collection": collection, + "workbook_registry": workbook_registry, "generated_at": now_kst_iso(), } +def load_workbook_sheet_registry() -> dict[str, Any]: + try: + payload = json.loads(WORKBOOK_JSON.read_text(encoding="utf-8")) + except Exception: + payload = {} + metadata = payload.get("metadata") if isinstance(payload, dict) else {} + sheets = metadata.get("sheets_included") if isinstance(metadata, dict) else [] + sheet_headers = metadata.get("sheet_headers") if isinstance(metadata, dict) else {} + data = payload.get("data") if isinstance(payload, dict) else {} + if not isinstance(sheets, list): + sheets = [] + if not isinstance(data, dict): + data = {} + entries: list[dict[str, Any]] = [] + for sheet in sheets: + sheet_name = str(sheet) + source_role = "derived_report_evidence" + if sheet_name in {"settings", "account_snapshot"}: + destination = "snapshot_admin.db" + kind = "workspace_db" + purpose = "workspace_edit" + source_role = "canonical_db" + elif sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + elif sheet_name in {"sector_universe_refresh_audit", "daily_history", "event_calendar", "pa1_feedback", "alpha_history", "backdata_feature_bank", "sell_priority", "harness_context", "monthly_history", "sector_flow_history", "sector_universe", "core_satellite", "universe", "event_risk", "macro", "sector_flow"}: + destination = "GatherTradingData.json" + if sheet_name in {"sector_universe_refresh_audit"}: + purpose = "refresh_audit" + elif sheet_name in {"daily_history"}: + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name in {"harness_context"}: + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name in {"sector_flow"}: + purpose = "flow_leadership" + else: + purpose = "json_payload" + kind = "json_payload" + else: + destination = "unknown" + kind = "unmapped" + purpose = "unmapped" + header_meta = sheet_headers.get(sheet_name) if isinstance(sheet_headers, dict) else {} + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": sheet_name in data, + "row_count": _sheet_payload_row_count(data.get(sheet_name), header_meta), + } + ) + explicit_target_sheets = [ + "sector_universe_refresh_audit", + "daily_history", + "event_calendar", + "pa1_feedback", + "alpha_history", + "backdata_feature_bank", + "sell_priority", + "harness_context", + "monthly_history", + "sector_flow_history", + "sector_universe", + "core_satellite", + "universe", + "event_risk", + "macro", + "sector_flow", + "data_feed", + ] + existing_sheets = {item["sheet"] for item in entries} + for sheet_name in explicit_target_sheets: + if sheet_name in existing_sheets: + continue + if sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + else: + destination = "GatherTradingData.json" + kind = "json_payload" + source_role = "derived_report_evidence" + if sheet_name == "sector_universe_refresh_audit": + purpose = "refresh_audit" + elif sheet_name == "daily_history": + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name == "harness_context": + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name == "sector_flow": + purpose = "flow_leadership" + else: + purpose = "json_payload" + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": kind == "json_payload", + "row_count": 0, + } + ) + return { + "xlsx_path": str(WORKBOOK_XLSX), + "json_path": str(WORKBOOK_JSON), + "json_role": "derived_report_evidence", + "sheet_count": len(entries), + "entries": entries, + "unmapped_sheets": [item["sheet"] for item in entries if item["kind"] == "unmapped"], + } + + +def _sheet_payload_row_count(value: Any, header_meta: Any | None = None) -> int | None: + if isinstance(header_meta, dict) and isinstance(header_meta.get("row_count"), int): + return int(header_meta["row_count"]) + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return len(value) + if value is None: + return 0 + return None + + +def run_collection_job( + *, + sqlite_db: Path | None = None, + input_json: Path | None = None, + output_json: Path | None = None, + allow_naver_fallback: bool = False, + include_live_kis: bool = False, + kis_account: str = "real", +) -> dict[str, Any]: + sqlite_db = sqlite_db or KIS_COLLECTION_DB + input_json = input_json or (ROOT / "GatherTradingData.json") + output_json = output_json or KIS_COLLECTION_REPORT + cmd = [ + sys.executable, + str(ROOT / "tools" / "run_kis_data_collection_v1.py"), + "--input-json", + str(input_json), + "--sqlite-db", + str(sqlite_db), + "--output-json", + str(output_json), + "--kis-account", + kis_account, + ] + if allow_naver_fallback: + cmd.append("--allow-naver-fallback") + if not include_live_kis: + cmd.append("--no-live-kis") + + started_at = now_kst_iso() + started = time.perf_counter() + proc = subprocess.run( + cmd, + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + finished_at = now_kst_iso() + elapsed_ms = round((time.perf_counter() - started) * 1000.0, 1) + summary = {} + if output_json.exists(): + try: + loaded = json.loads(output_json.read_text(encoding="utf-8")) + summary = loaded if isinstance(loaded, dict) else {} + except Exception: + summary = {} + state = {} + try: + state = load_collection_dashboard_state(sqlite_db, output_json) + except Exception: + state = {} + return { + "status": "PASS" if proc.returncode == 0 else "FAIL", + "started_at": started_at, + "finished_at": finished_at, + "elapsed_ms": elapsed_ms, + "command": cmd, + "returncode": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + "summary": summary, + "state": state, + } + + +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 _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) @@ -389,6 +637,118 @@ def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]: return payload +def render_home_html() -> str: + return """ + + + + + Snapshot Admin Home + + + +
+
+

Snapshot Admin Home

+
이 화면은 운영 판단용입니다. 편집, 승인, 수집이 모두 한 곳에 섞여 있으면 목적이 흐려지므로, 먼저 해야 할 일만 보여줍니다.
+
+
+
1. Workspace
+ 데이터를 고치고 저장 +

settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.

+
+
+
2. Collection
+ 수집 실행과 결과 확인 +

수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.

+
+
+
3. Tables
+ DB와 JSON 증빙을 분리 검토 +

DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.

+
+
+ +
테이블 브라우저는 보조 경로로 /tables에 둡니다.
+
+
+ + +""" + + +def render_inspector_html() -> str: + return """ + + + + + Snapshot Inspector + + + +
+
+

Snapshot Inspector

+
Row-level operations move here so the workspace stays focused on editing.
+
+ Open workspace + Home + + + +
+
Select a domain and target_ref, then load.
+

+    
+
+ + + +""" + + def render_index_html() -> str: return """ @@ -397,53 +757,84 @@ def render_index_html() -> str: Snapshot Admin @@ -949,6 +1400,7 @@ def render_index_html() -> str:
Open collection dashboard Open table browser + Open home
@@ -972,6 +1424,29 @@ def render_index_html() -> str:
Snapshot approval state and lock state are pinned here for immediate review.
+
+
+
+ Now + Loading current workspace status... + This page summarizes the canonical SQLite workspace and collection state. +
+
+ Next action + Loading... + The page will tell you whether to edit, approve, unlock, or collect. + +
+
+ Collection + Loading... + Latest collector run and result summary. +
+
+
@@ -1004,10 +1479,15 @@ def render_index_html() -> str:
-
-
+
+

Approval & Locks

+ open when you need to approve or lock rows +
+
+
+
@@ -1015,8 +1495,6 @@ def render_index_html() -> str:
-
-
settings approval
@@ -1046,22 +1524,29 @@ def render_index_html() -> str:
-
+ + -
-
+
+

KIS Collection

+ open when you want to run or inspect collector output +
+
+
+
-
-
collection: loading...
+
Collection trend
+
+
@@ -1079,20 +1564,23 @@ def render_index_html() -> str:
-
+ -
-
+
+

Selection Inspector

+ open when you need row-level operations +
+
+
+
-
-
No row selected.
@@ -1115,7 +1603,7 @@ def render_index_html() -> str:
-
+
@@ -2230,9 +2718,71 @@ def render_index_html() -> str: ].join("\n"); } + function summarizeNextAction() { + const settingsErrors = (state.validation?.settings || []).length; + const snapshotErrors = (state.validation?.account_snapshot || []).length; + const settingsApproval = state.approvalSettings?.status || "MISSING"; + const snapshotApproval = state.approvalSnapshot?.status || "MISSING"; + const lockCount = (state.locks || []).length; + if (settingsErrors || snapshotErrors) { + return { + title: "Fix validation issues", + detail: `${settingsErrors + snapshotErrors} validation issue(s) remain. Edit the workspace before approving or exporting.`, + }; + } + if (settingsApproval !== "APPROVED" || snapshotApproval !== "APPROVED") { + return { + title: "Approve the current workspace", + detail: `Approval status is settings=${settingsApproval}, snapshot=${snapshotApproval}. Review and approve the rows that are ready.`, + }; + } + if (lockCount > 0) { + return { + title: "Resolve active locks", + detail: `${lockCount} lock(s) are active. Unlock only after confirming the affected rows are stable.`, + }; + } + return { + title: "Workspace is ready", + detail: "Approvals are clear and no active locks remain. Export or continue with downstream work.", + }; + } + + function summarizeCollectionAction(collection, latestRun, latestReport) { + const runStatus = String(latestRun?.status || "").trim(); + const runId = String(latestRun?.run_id || "").trim(); + if (!runId) { + return { + title: "No collection run yet", + detail: "Use Collect now to create the first collector run and populate the run history.", + }; + } + if (runStatus === "FAIL") { + return { + title: "Collector failed", + detail: `Latest run ${runId} failed. Check the run log and error rows before retrying.`, + }; + } + return { + title: `Latest collection ${runStatus || "PASS"}`, + detail: `Run ${runId} finished at ${latestRun?.finished_at || "N/A"}. Report status is ${latestReport?.status || "N/A"}.`, + }; + } + + function setPanelOpen(panelId, shouldOpen) { + const panel = document.getElementById(panelId); + if (panel) panel.open = Boolean(shouldOpen); + } + function renderMeta() { const settingsApproval = state.approvalSettings || {}; const snapshotApproval = state.approvalSnapshot || {}; + const settingsErrors = state.validation?.settings || []; + const snapshotErrors = state.validation?.account_snapshot || []; + const lockCount = (state.locks || []).length; + const collection = state.collection || {}; + const latestRun = collection.latest_run || {}; + const latestReport = collection.latest_report || {}; const version = state.version || {}; const git = version.git || {}; const source = version.source || {}; @@ -2272,6 +2822,40 @@ def render_index_html() -> str: `change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` + (changeLogFilter ? `, filtered=${visibleChanges.length}` : "") + (recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : ""); + const nextAction = summarizeNextAction(); + const collectionAction = summarizeCollectionAction(collection, latestRun, latestReport); + const heroNow = document.getElementById("heroNow"); + const heroNowDetail = document.getElementById("heroNowDetail"); + const heroNextAction = document.getElementById("heroNextAction"); + const heroNextDetail = document.getElementById("heroNextDetail"); + const heroPrimaryAction = document.getElementById("heroPrimaryAction"); + const heroCollection = document.getElementById("heroCollection"); + const heroCollectionDetail = document.getElementById("heroCollectionDetail"); + if (heroNow) { + heroNow.textContent = `Workspace rows: settings=${state.summary?.settings_rows ?? 0}, snapshot=${state.summary?.account_snapshot_rows ?? 0}`; + } + if (heroNowDetail) { + heroNowDetail.textContent = `Approval: settings=${settingsApproval.status || "MISSING"}, snapshot=${snapshotApproval.status || "MISSING"} | locks=${locks.length}`; + } + if (heroNextAction) heroNextAction.textContent = nextAction.title; + if (heroNextDetail) heroNextDetail.textContent = nextAction.detail; + if (heroPrimaryAction) { + heroPrimaryAction.textContent = nextAction.title; + const workspaceNeedsAttention = settingsErrors.length > 0 || snapshotErrors.length > 0 || settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0; + heroPrimaryAction.href = workspaceNeedsAttention ? "/" : "/collection"; + } + if (heroCollection) { + heroCollection.textContent = collectionAction.title; + } + if (heroCollectionDetail) { + heroCollectionDetail.textContent = collectionAction.detail; + } + const shouldOpenWorkspace = settingsErrors.length > 0 || snapshotErrors.length > 0; + const shouldOpenApproval = !shouldOpenWorkspace && (settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0); + const shouldOpenCollection = !shouldOpenWorkspace && !shouldOpenApproval && (!latestRun.run_id || latestRun.status === "FAIL"); + setPanelOpen("approvalPanel", shouldOpenApproval); + setPanelOpen("collectionPanel", shouldOpenCollection); + setPanelOpen("selectionPanel", Boolean(state.selected?.domain) && !shouldOpenWorkspace && !shouldOpenApproval && !shouldOpenCollection); document.getElementById("bannerApprovalSummary").textContent = `settings=${settingsApproval.status || "MISSING"} | snapshot=${snapshotApproval.status || "MISSING"}`; document.getElementById("bannerLockSummary").textContent = @@ -2290,17 +2874,27 @@ def render_index_html() -> str: }).join("\n"); const timelineEl = document.getElementById("changeTimeline"); if (timelineEl) { + const changeSummary = visibleChanges.slice(0, 12).map((item) => ({ + ts: String(item.created_at || "").slice(5, 16), + label: `${item.domain}:${item.action}`, + })); 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 ` -
- ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)} - ${esc(JSON.stringify({ before, after }, null, 2))} -
- `; - }).join("") + ? ` +
Recent change summary
+
+ ${changeSummary.map((item) => `${esc(item.ts || "N/A")} · ${esc(item.label)}`).join("")} +
+ ${visibleChanges.map((item) => { + const before = Array.isArray(item.before_json) ? item.before_json : []; + const after = Array.isArray(item.after_json) ? item.after_json : []; + return ` +
+ ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)} + ${esc(JSON.stringify({ before, after }, null, 2))} +
+ `; + }).join("")} + ` : "
No timeline entries.
"; } } @@ -2335,8 +2929,9 @@ def render_index_html() -> str: const runsEl = document.getElementById("collectionRuns"); const snapshotsEl = document.getElementById("collectionSnapshots"); const errorsEl = document.getElementById("collectionErrors"); + const timelineEl = document.getElementById("collectionTimeline"); const detailEl = document.getElementById("collectionDetail"); - if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !detailEl) return; + if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !timelineEl || !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`; @@ -2346,6 +2941,7 @@ def render_index_html() -> str: latestRun.run_id ? `latest_run=${latestRun.run_id}` : "", latestReport.status ? `latest_status=${latestReport.status}` : "", latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "", + state.version?.source?.latest_updated_at ? `final_updated_at=${state.version.source.latest_updated_at}` : "", filterText ? `filter=${filterText}` : "", ].filter(Boolean).join(" | "); const sourceCounts = latestReport.source_counts || {}; @@ -2378,7 +2974,7 @@ def render_index_html() -> str: const key = currentCollectionSelectionKey(kind, item, index); const selected = selection.kind === kind && selection.key === key; return ` - @@ -2388,6 +2984,23 @@ def render_index_html() -> str: 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 activityRows = [ + ...recentRuns.map((item, index) => ({ kind: "run", ts: String(item.started_at || item.created_at || ""), label: `${item.status || "RUN"} ${item.collector_name || "collector"} ${item.run_id || ""}`, key: currentCollectionSelectionKey("run", item, index) })), + ...recentSnapshots.map((item, index) => ({ kind: "snapshot", ts: String(item.created_at || item.as_of_date || ""), label: `${item.dataset_name || "snapshot"}:${item.ticker || ""} ${item.source_status || ""}`, key: currentCollectionSelectionKey("snapshot", item, index) })), + ...recentErrors.map((item, index) => ({ kind: "error", ts: String(item.created_at || ""), label: `${item.source_name || "error"}:${item.error_kind || ""} ${item.ticker || ""}`, key: currentCollectionSelectionKey("error", item, index) })), + ].sort((a, b) => String(a.ts).localeCompare(String(b.ts))).slice(-18); + timelineEl.innerHTML = activityRows.length + ? activityRows.map((item, index) => { + const previous = activityRows[index - 1]; + const currentDate = String(item.ts || "").slice(0, 10); + const previousDate = previous ? String(previous.ts || "").slice(0, 10) : ""; + const dateHeader = !index || currentDate !== previousDate + ? `
${esc(currentDate || "Unknown date")}
` + : ""; + const selected = selection.kind === item.kind && selection.key === item.key; + return `${dateHeader}`; + }).join("") + : `
No unified activity yet.
`; const selectedItem = (() => { const key = String(selection.key || "").trim(); if (!key) return null; @@ -2573,7 +3186,7 @@ def render_index_html() -> str: } function _tsvRows(tsvText) { - return String(tsvText || "").replace(/\r/g, "").split("\n").filter((line) => line.trim() !== ""); + return String(tsvText || "").replace(/\\r/g, "").split("\\n").filter((line) => line.trim() !== ""); } function applyBatchPaste() { @@ -2708,25 +3321,62 @@ def render_collection_html() -> str: 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; } + .wrap { max-width: 1280px; margin: 0 auto; padding: 8px 12px 20px; } + .panel { border: 1px solid rgba(255,255,255,.08); border-radius: 12px; overflow: hidden; background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95)); box-shadow: 0 8px 24px rgba(0,0,0,.24); } + .panel-head { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; padding: 10px 12px 8px; border-bottom: 1px solid rgba(255,255,255,.06); } + .actions { display:flex; flex-wrap:wrap; gap:6px; } + button, a.btn { border: 1px solid rgba(255,255,255,.12); background: var(--chip); color: var(--text); padding: 6px 10px; border-radius: 8px; 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; } + button.primary.run-live { background: linear-gradient(135deg, #22c55e, #16a34a); box-shadow: 0 0 0 1px rgba(34,197,94,.18) inset; } + .pane { padding: 8px 12px 12px; } + .grid { display:grid; gap:8px; 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; } + .metrics { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:6px; margin-top:6px; } + .metric { border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 8px; 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; } + .filter-row { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; } + .filter-row input { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 220px; } + .run-form { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; } + .run-form input, .run-form select { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 180px; } + .run-note { margin-top: 6px; color: var(--muted); font-size: 12px; } + .run-banner { + display: none; + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,.08); + background: rgba(3,7,18,.45); + color: var(--text); + font-size: 12px; + } + .run-banner.success { display: block; border-color: rgba(34,197,94,.36); background: rgba(34,197,94,.12); } + .run-banner.error { display: block; border-color: rgba(248,113,113,.36); background: rgba(248,113,113,.12); } + .list { display:grid; gap:4px; margin-top: 6px; } + .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-run { border-left: 4px solid #3b82f6 !important; background: rgba(59, 130, 246, 0.12) !important; } + .collection-item-snapshot { border-left: 4px solid #10b981 !important; background: rgba(16, 185, 129, 0.12) !important; } + .collection-item-error { border-left: 4px solid #ef4444 !important; background: rgba(239, 68, 68, 0.12) !important; } + .collection-item code { + display: block; + margin-top: 6px; + white-space: pre-wrap; + color: #cbd5e1; + } 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; } } @@ -2740,7 +3390,12 @@ def render_collection_html() -> str:
+
Execution / Status / Result
collection: loading...
+
run: idle
+
progress: idle
+
stage: idle
+
result: pending
@@ -2755,6 +3410,26 @@ def render_collection_html() -> str:
+
+ + + + + +
mode: real / live
+
+
Real mode is selected by default. Offline replay is available only when explicitly enabled.
+
live source: unknown
+
Auto refreshes every 15 seconds while this page is open.
+
@@ -2767,17 +3442,21 @@ def render_collection_html() -> str:
Recent collector errors
+
Unified activity timeline
+
-
Collection detail
+
Collection detail

+            
Run log
+

           
@@ -2937,12 +3897,63 @@ def render_tables_html() -> str:
+
+
+
+
+
DB 먼저 / JSON은 증빙
+

DB별 수정, JSON별 검토, 수집 증빙 확인

+
Workspace rows are editable only in the canonical DB. Collection and strategy views are read-only proof surfaces.
+
+ +
+
+ + + + +
+
+ DB 먼저 + JSON은 증빙 + Edit only canonical rows +
+
+
+
+
DB tables
+
Editable source of truth. Only canonical workspace rows are mutated here.
+
+
+
+
+
Workbook sheets
+
Derived report evidence. These sheets summarize DB-backed outputs and run history.
+
+
+
+
version: loading...
+
+ Checking whether the table combo covers the target workbook sheets... +
+
+ Workbook sheets only + + sheet: none + surface: none +
+
+
- + + read only
@@ -2954,31 +3965,162 @@ def render_tables_html() -> str:
+
Save applies only to the canonical workspace DB. This selector is for DB tables only; workbook sheets are handled above.
-
+
+ Workspace + Collection + Strategy + JSON +
+
+ Registry details +
+
+
+
+
Open only when you need the table/sheet mapping.
+
+
+
+
+
DB tables
+
+
+ + + + + + + + + + +
TableDBRowsEdit
+
+
+
+
+
+
+
Derived report registry
+
+
+
+ DB가 원천이고 JSON은 DB 기반 파생 보고서 증빙이다. 이 화면은 그 관계만 보여준다. +
+
+
+ + + + + + + + + + + + + +
SheetDestinationKindSource RolePurposeRowsRecorded surface
+
+
+
+
+
+
+ History details +
Recent workspace change log and collector run history.
+
+
+
+ + + + + +
WhenDomainActionTarget
+
+
+
+
+ + + + + +
RunStatusStartedFinished
+
+
+
+
+
+ Collection purpose: monitor collector runs and snapshots. + Latest run summary loads from `/api/state`. +
+ DB별 / JSON별 조회 기준을 먼저 확인한 뒤 수정하세요. + This page is intentionally a triage surface, not a generic table dump. 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.
-
-
- Table browser ready - 0 rows - filter=none - page=0 +
+ Workspace and DB view +
Purpose: edit canonical workspace rows only.
+
+
+ Table browser ready + 0 rows + filter=none + page=0 +
+
If a table shows "no rows", the current filter or selected table has no visible records.
-
If a table shows "no rows", the current filter or selected table has no visible records.
-
-
- - - - - - -
-
+
+ + + + + + +
+
+ +
+ Derived JSON evidence view +
Purpose: inspect DB-backed JSON evidence and row payloads.
+
+
+
+
Derived JSON Evidence Preview
+
Latest report JSON generated after DB-backed collection from Temp/kis_data_collection_v1.json
+
+ loading... +
+
+
+
+
+
Select a workbook sheet to inspect its registry detail.
+
No workbook sheet selected.
+
+ + + + + +
+
+
Click a row to inspect the derived JSON payload.
+
No JSON row selected.
+
version: loading...
+
+
+
@@ -2986,6 +4128,8 @@ def render_tables_html() -> str: