From 02ac26fe025fd8f9ee3a0a138599ce438b2926a9 Mon Sep 17 00:00:00 2001 From: Yi Date: Sat, 11 Apr 2026 22:27:51 +0200 Subject: [PATCH] Add local web view for sim db --- README.md | 24 ++++++ sim_db.py | 219 +++++++++++++++++++++++++++++++++++++++++++++++++ test_sim_db.py | 23 +++++- 3 files changed, 264 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b052fc1..05bd058 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ This means repeated runs of the same case are supported cleanly as long as the r ./sim_db list --table ./sim_db done --job-id + +# local web view (opens browser) +./sim_db view ``` ## CLI behavior @@ -48,6 +51,27 @@ Use `./sim_db add --help` for full help and examples. - if multiple rows share the same case label, the CLI tells you to use `--job-id` +## Local view UI + +Use a lightweight local web page to browse all rows/columns and quickly change status: + +```bash +./sim_db view +``` + +What it supports: +- shows all rows and all columns from your local DB +- text filtering across all columns +- obvious sorting controls (date/field ascending or descending) +- one-click `start` and `done` actions per row + +Optional flags: + +```bash +./sim_db view --port 8765 --host 127.0.0.1 +./sim_db view --no-open # keep server running but do not auto-open browser +``` + ## Search A new `find` command is available for case-insensitive search. diff --git a/sim_db.py b/sim_db.py index e305efe..1d31a53 100755 --- a/sim_db.py +++ b/sim_db.py @@ -12,9 +12,13 @@ import socket import sqlite3 import sys +import threading +import webbrowser from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Mapping +from urllib.parse import urlparse ALLOWED_STATUS = {'start', 'restart', 'done'} DEFAULT_DB_PATH = os.path.expanduser('~/sim_db.csv') @@ -653,6 +657,214 @@ def sync_import(db_path: str, in_path: str) -> dict[str, Any]: conn.close() +def mark_start(job_id: str | None = None, db_path: str = DEFAULT_DB_PATH, case: str | None = None) -> None: + if job_id is None: + _, rows = _read_sim_db(db_path) + job_id = resolve_job_id(rows, case=case) + now = _now_iso() + upd_case_by_job_id(db_path, job_id, {'status': 'start', 'updated_at': now}) + print(f"Job '{job_id}' marked as start") + + +def _view_payload(db_path: str) -> dict[str, Any]: + rows = list_view(db_path=db_path, sort_by='updated_at', desc=True) + columns = _ordered_fieldnames([*{k for row in rows for k in row.keys()}]) + return {'rows': rows, 'columns': columns} + + +def _view_html() -> str: + return ''' + + + + mini_sim_db view + + + +

mini_sim_db local view

+
+ + + + + + +
+
+ + +''' + + +def run_local_view(db_path: str, host: str = '127.0.0.1', port: int = 8765, open_browser: bool = True) -> None: + class ViewHandler(BaseHTTPRequestHandler): + def _write_json(self, code: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode('utf-8') + self.send_response(code) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get('Content-Length', '0') or '0') + raw = self.rfile.read(length) if length > 0 else b'{}' + try: + return json.loads(raw.decode('utf-8') or '{}') + except json.JSONDecodeError: + return {} + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path in ('/', '/index.html'): + body = _view_html().encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if path == '/api/rows': + self._write_json(200, _view_payload(db_path)) + return + self._write_json(404, {'error': 'not found'}) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + payload = self._read_json() + job_id = str(payload.get('job_id', '')).strip() + if not job_id: + self._write_json(400, {'error': 'missing job_id'}) + return + try: + if path == '/api/start': + mark_start(job_id=job_id, db_path=db_path) + self._write_json(200, {'ok': True, 'job_id': job_id, 'status': 'start'}) + return + if path == '/api/done': + mark_done(job_id=job_id, db_path=db_path) + self._write_json(200, {'ok': True, 'job_id': job_id, 'status': 'done'}) + return + except Exception as exc: + self._write_json(400, {'error': str(exc)}) + return + self._write_json(404, {'error': 'not found'}) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return + + server = ThreadingHTTPServer((host, port), ViewHandler) + local_url = f'http://{host}:{port}/' + print(f'Local view running at {local_url}') + print('Press Ctrl+C to stop.') + if open_browser: + threading.Thread(target=lambda: webbrowser.open(local_url), daemon=True).start() + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + def _format_table(rows: list[dict[str, str]]) -> str: cols = ['case', 'status', 'job_id', 'bin', 'inp', 'updated_at', 'run_host', 'note'] widths = {c: len(c) for c in cols} @@ -731,6 +943,11 @@ def _build_cli() -> argparse.ArgumentParser: p_list.add_argument('--limit', type=int, default=None, help='Maximum number of rows to show.') p_list.add_argument('--table', action='store_true', help='Show compact table view (easy inspection)') + p_view = sub.add_parser('view', help='Open a local web UI for browsing/filtering/sorting rows and quick status actions') + p_view.add_argument('--db', default=DEFAULT_DB_PATH, help='Path to DB (CSV path auto-maps to SQLite)') + p_view.add_argument('--host', default='127.0.0.1', help='Bind host for local web UI (default: 127.0.0.1)') + p_view.add_argument('--port', type=int, default=8765, help='Bind port for local web UI (default: 8765)') + p_view.add_argument('--no-open', action='store_true', help='Do not auto-open a browser tab') p_find = sub.add_parser( 'find', @@ -822,6 +1039,8 @@ def main(argv: list[str] | None = None) -> int: case = row.get('case', '') detail = {k: v for k, v in row.items() if k != 'case'} print(f'{case}: {detail}') + elif args.command == 'view': + run_local_view(db_path=args.db, host=args.host, port=args.port, open_browser=not args.no_open) elif args.command == 'import-csv': import_csv(args.csv, args.db) elif args.command == 'sync-status': diff --git a/test_sim_db.py b/test_sim_db.py index dc8e648..88cdefe 100644 --- a/test_sim_db.py +++ b/test_sim_db.py @@ -6,7 +6,7 @@ import time import unittest -from sim_db import add_sim_item, derive_job_id, find_items, import_csv, init_sim_db, list_items, list_view, mark_done, resolve_job_id, sync_export, sync_import, sync_status +from sim_db import _view_payload, add_sim_item, derive_job_id, find_items, import_csv, init_sim_db, list_items, list_view, mark_done, mark_start, resolve_job_id, sync_export, sync_import, sync_status class TestSimpleCliFunctions(unittest.TestCase): @@ -64,6 +64,19 @@ def test_status_validation(self): with self.assertRaises(ValueError): add_sim_item(case='case002', inp='job.inp', bin_name='solver.bin', status='running', db_path=self.db_path) + def test_mark_start_and_view_payload(self): + init_sim_db(self.db_path) + add_sim_item(case='c3', inp='a.inp', bin_name='solver', status='done', db_path=self.db_path, note='n1') + row = list_view(self.db_path)[0] + mark_start(job_id=row['job_id'], db_path=self.db_path) + row_after = list_view(self.db_path)[0] + self.assertEqual(row_after['status'], 'start') + payload = _view_payload(self.db_path) + self.assertIn('rows', payload) + self.assertIn('columns', payload) + self.assertIn('status', payload['columns']) + self.assertEqual(payload['rows'][0]['job_id'], row['job_id']) + def test_csv_import(self): csv_file = os.path.join(self.tmp_dir.name, 'legacy.csv') with open(csv_file, 'w', encoding='utf-8') as f: @@ -104,6 +117,11 @@ def test_cli_find(self): self.assertEqual(found.returncode, 0) self.assertIn('Wing_Load', found.stdout) + def test_cli_view_help(self): + r = self._run('view', '--help') + self.assertEqual(r.returncode, 0) + self.assertIn('--no-open', r.stdout) + def test_cli_done_by_job_id(self): self.assertEqual(self._run('init').returncode, 0) self.assertEqual(self._run('add', '--case', 'c2', '--inp', 'a.inp', '--bin', 'solver', '--status', 'start').returncode, 0) @@ -140,7 +158,8 @@ def test_sync_export_and_import(self): self.assertEqual(status_before['pending_cases'], 1) out = sync_export(self.db_path, self.sync_file) self.assertEqual(out['exported'], 1) - payload = json.load(open(self.sync_file, 'r', encoding='utf-8')) + with open(self.sync_file, 'r', encoding='utf-8') as f: + payload = json.load(f) self.assertEqual(payload['count'], 1) imported = sync_import(self.db_path, self.sync_file) self.assertEqual(imported['skipped'], 1)