1
0

feat: add DB Explorer for read-only SQLite inspection

Adds /db-explorer page and JSON API to browse all 15 SQLite databases
in data/. Sidebar lists databases with sizes, clicking one renders all
tables as scrollable data tables with row truncation at 200.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trip T
2026-03-12 10:41:13 -04:00
parent 765e0f79c7
commit bc38fee817
5 changed files with 260 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -78,6 +78,7 @@
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/grok/status" class="mc-test-link mc-link-grok">GROK</a>
<a href="/db-explorer" class="mc-test-link">DB EXPLORER</a>
</div>
</div>
<div class="mc-nav-dropdown">
@@ -137,6 +138,7 @@
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/router/status" class="mc-mobile-link">ROUTER</a>
<a href="/grok/status" class="mc-mobile-link">GROK</a>
<a href="/db-explorer" class="mc-mobile-link">DB EXPLORER</a>
<div class="mc-mobile-section-label">COMMERCE</div>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>

View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% from "macros.html" import panel %}
{% block title %}DB Explorer - Timmy Time{% endblock %}
{% block content %}
<div class="db-explorer-container py-3">
<div class="db-explorer-header">
<div class="db-explorer-title">DB EXPLORER</div>
<span class="db-explorer-subtitle">Read-only view of all SQLite databases in data/</span>
</div>
<div class="row g-3 mt-2">
<!-- Database list sidebar -->
<div class="col-md-3">
{% call panel("DATABASES") %}
<div class="db-list">
{% for db in databases %}
<a href="/db-explorer?db={{ db.name }}"
class="db-list-item {% if selected_db == db.name %}active{% endif %}">
<span class="db-name">{{ db.name }}</span>
<span class="db-size">{{ db.size_kb }} KB</span>
</a>
{% endfor %}
{% if not databases %}
<div class="text-muted p-2">No databases found</div>
{% endif %}
</div>
{% endcall %}
</div>
<!-- Table content -->
<div class="col-md-9">
{% 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 %}
<div class="text-danger">Error: {{ table.error }}</div>
{% elif table.rows %}
<div class="table-responsive db-table-wrap">
<table class="table table-sm table-dark db-table">
<thead>
<tr>
{% for col in table.columns %}
<th>{{ col }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table.rows %}
<tr>
{% for col in table.columns %}
<td class="db-cell">{{ row[col] if row[col] is not none else '' }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if table.truncated %}
<div class="db-truncated">Showing {{ table.rows|length }} of {{ table.total_count }} rows</div>
{% endif %}
{% else %}
<div class="text-muted">Empty table ({{ table.columns|length }} columns: {{ table.columns|join(', ') }})</div>
{% endif %}
{% endcall %}
{% endfor %}
{% else %}
{% call panel("INFO") %}
<div class="text-muted">No tables found in {{ selected_db }}.db</div>
{% endcall %}
{% endif %}
{% else %}
{% call panel("SELECT A DATABASE") %}
<div class="text-muted">Choose a database from the sidebar to inspect its tables.</div>
{% endcall %}
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -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; }