diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 1569b8b..c698648 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -28,6 +28,7 @@ from dashboard.routes.agents import router as agents_router from dashboard.routes.briefing import router as briefing_router from dashboard.routes.calm import router as calm_router from dashboard.routes.chat_api import router as chat_api_router +from dashboard.routes.db_explorer import router as db_explorer_router from dashboard.routes.discord import router as discord_router from dashboard.routes.experiments import router as experiments_router from dashboard.routes.grok import router as grok_router @@ -445,6 +446,7 @@ app.include_router(loop_qa_router) app.include_router(system_router) app.include_router(paperclip_router) app.include_router(experiments_router) +app.include_router(db_explorer_router) app.include_router(cascade_router) diff --git a/src/dashboard/routes/db_explorer.py b/src/dashboard/routes/db_explorer.py new file mode 100644 index 0000000..7af1751 --- /dev/null +++ b/src/dashboard/routes/db_explorer.py @@ -0,0 +1,144 @@ +"""DB Explorer — read-only view of all SQLite databases in data/.""" + +import asyncio +import logging +import sqlite3 +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from config import settings +from dashboard.templating import templates + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/db-explorer", tags=["db-explorer"]) + +DATA_DIR = Path(settings.repo_root) / "data" + +# Cap rows per table to avoid blowing up the response +MAX_ROWS = 200 + + +def _discover_databases() -> list[dict]: + """Return metadata for every .db file in data/.""" + if not DATA_DIR.exists(): + return [] + dbs = [] + for path in sorted(DATA_DIR.glob("*.db")): + try: + size = path.stat().st_size + except OSError: + size = 0 + dbs.append({"name": path.stem, "path": str(path), "size_kb": round(size / 1024, 1)}) + return dbs + + +def _query_database(db_path: str) -> dict: + """Open a database read-only and return all tables with their rows.""" + result = {"tables": {}, "error": None} + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + except Exception as exc: + result["error"] = str(exc) + return result + + try: + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + for (table_name,) in tables: + try: + rows = conn.execute( + f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608 + ).fetchall() + columns = ( + [ + desc[0] + for desc in conn.execute( + f"SELECT * FROM [{table_name}] LIMIT 0" + ).description + ] + if rows + else [] + ) # noqa: S608 + if not columns and rows: + columns = list(rows[0].keys()) + elif not columns: + # Get columns even for empty tables + cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608 + columns = [r[1] for r in cursor.fetchall()] + count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608 + result["tables"][table_name] = { + "columns": columns, + "rows": [dict(r) for r in rows], + "total_count": count, + "truncated": count > MAX_ROWS, + } + except Exception as exc: + result["tables"][table_name] = { + "error": str(exc), + "columns": [], + "rows": [], + "total_count": 0, + "truncated": False, + } + except Exception as exc: + result["error"] = str(exc) + finally: + conn.close() + + return result + + +# --------------------------------------------------------------------------- +# HTML page +# --------------------------------------------------------------------------- + + +@router.get("", response_class=HTMLResponse) +async def db_explorer_page(request: Request, db: str = ""): + """Render the DB explorer page. If ?db=name, show that database's tables.""" + databases = await asyncio.to_thread(_discover_databases) + selected = None + tables_data = {} + + if db: + db_path = DATA_DIR / f"{db}.db" + if db_path.exists(): + selected = db + result = await asyncio.to_thread(_query_database, str(db_path)) + tables_data = result.get("tables", {}) + + return templates.TemplateResponse( + request, + "db_explorer.html", + { + "databases": databases, + "selected_db": selected, + "tables_data": tables_data, + }, + ) + + +# --------------------------------------------------------------------------- +# JSON API +# --------------------------------------------------------------------------- + + +@router.get("/api/databases", response_class=JSONResponse) +async def api_list_databases(): + """List all SQLite databases.""" + return JSONResponse(await asyncio.to_thread(_discover_databases)) + + +@router.get("/api/databases/{db_name}", response_class=JSONResponse) +async def api_query_database(db_name: str): + """Return all tables and rows for a specific database.""" + db_path = DATA_DIR / f"{db_name}.db" + if not db_path.exists(): + return JSONResponse({"error": f"Database '{db_name}' not found"}, status_code=404) + result = await asyncio.to_thread(_query_database, str(db_path)) + return JSONResponse(result) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index c7a82f8..3ce1ee4 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -78,6 +78,7 @@ EVENTS ROUTER GROK + DB EXPLORER
@@ -137,6 +138,7 @@ EVENTS ROUTER GROK + DB EXPLORER
COMMERCE
LEDGER CREATIVE diff --git a/src/dashboard/templates/db_explorer.html b/src/dashboard/templates/db_explorer.html new file mode 100644 index 0000000..95ba912 --- /dev/null +++ b/src/dashboard/templates/db_explorer.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% from "macros.html" import panel %} + +{% block title %}DB Explorer - Timmy Time{% endblock %} + +{% block content %} +
+
+
DB EXPLORER
+ Read-only view of all SQLite databases in data/ +
+ +
+ +
+ {% call panel("DATABASES") %} +
+ {% for db in databases %} + + {{ db.name }} + {{ db.size_kb }} KB + + {% endfor %} + {% if not databases %} +
No databases found
+ {% endif %} +
+ {% endcall %} +
+ + +
+ {% if selected_db %} + {% if tables_data %} + {% for table_name, table in tables_data.items() %} + {% call panel(table_name | upper ~ " (" ~ table.total_count ~ " rows)") %} + {% if table.error %} +
Error: {{ table.error }}
+ {% elif table.rows %} +
+ + + + {% for col in table.columns %} + + {% endfor %} + + + + {% for row in table.rows %} + + {% for col in table.columns %} + + {% endfor %} + + {% endfor %} + +
{{ col }}
{{ row[col] if row[col] is not none else '' }}
+
+ {% if table.truncated %} +
Showing {{ table.rows|length }} of {{ table.total_count }} rows
+ {% endif %} + {% else %} +
Empty table ({{ table.columns|length }} columns: {{ table.columns|join(', ') }})
+ {% endif %} + {% endcall %} + {% endfor %} + {% else %} + {% call panel("INFO") %} +
No tables found in {{ selected_db }}.db
+ {% endcall %} + {% endif %} + {% else %} + {% call panel("SELECT A DATABASE") %} +
Choose a database from the sidebar to inspect its tables.
+ {% endcall %} + {% endif %} +
+
+
+{% endblock %} diff --git a/static/css/mission-control.css b/static/css/mission-control.css index 07ecca8..395b987 100644 --- a/static/css/mission-control.css +++ b/static/css/mission-control.css @@ -2463,3 +2463,33 @@ font-size: 0.7rem; } } + +/* ═══════════════════════════════════════════════════════════════ + DB Explorer + ═══════════════════════════════════════════════════════════════ */ + +.db-explorer-container { max-width: 1400px; margin: 0 auto; padding: 0 1rem; } +.db-explorer-header { margin-bottom: 0.5rem; } +.db-explorer-title { font-size: 1.4rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.05em; } +.db-explorer-subtitle { font-size: 0.75rem; color: var(--text-dim); } + +.db-list { display: flex; flex-direction: column; gap: 2px; } +.db-list-item { + display: flex; justify-content: space-between; align-items: center; + padding: 0.45rem 0.6rem; border-radius: 4px; + color: var(--text); text-decoration: none; + transition: background 0.15s; +} +.db-list-item:hover { background: var(--bg-card); color: var(--text-bright); } +.db-list-item.active { background: var(--purple); color: var(--text-bright); } +.db-name { font-size: 0.8rem; font-weight: 600; } +.db-size { font-size: 0.7rem; color: var(--text-dim); } +.db-list-item.active .db-size { color: var(--text-bright); opacity: 0.7; } + +.db-table-wrap { max-height: 500px; overflow: auto; } +.db-table { font-size: 0.75rem; } +.db-table th { position: sticky; top: 0; background: var(--bg-panel); color: var(--green); font-weight: 600; white-space: nowrap; z-index: 1; } +.db-table td { vertical-align: top; } +.db-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.db-cell:hover { white-space: normal; word-break: break-all; } +.db-truncated { font-size: 0.7rem; color: var(--amber); padding: 0.3rem 0; }