forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
144
src/dashboard/routes/db_explorer.py
Normal file
144
src/dashboard/routes/db_explorer.py
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
82
src/dashboard/templates/db_explorer.html
Normal file
82
src/dashboard/templates/db_explorer.html
Normal 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 %}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user