Skip to content

Commit bad71ce

Browse files
feat: add hidden debug dashboard
Add /debug route with comprehensive status overview: - Spec/implementation counts and coverage stats - Library coverage cards (impl count, avg/min/max scores) - Problem areas (low scores, missing previews/tags, oldest specs) - System health (database status, API response time) - Filterable table (search, incomplete, low scores, missing library) - Sortable by ID, title, avg score, updated date Backend: GET /debug/status endpoint with full statistics Frontend: DebugPage with MUI components Access: Triple-click on logo or direct /debug URL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a4d8524 commit bad71ce

7 files changed

Lines changed: 885 additions & 9 deletions

File tree

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
pyplots_exception_handler,
2424
)
2525
from api.routers import ( # noqa: E402
26+
debug_router,
2627
download_router,
2728
health_router,
2829
libraries_router,
@@ -132,6 +133,7 @@ async def add_cache_headers(request: Request, call_next):
132133
app.include_router(seo_router)
133134
app.include_router(og_images_router)
134135
app.include_router(proxy_router)
136+
app.include_router(debug_router)
135137

136138

137139
if __name__ == "__main__":

api/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""API routers."""
22

3+
from api.routers.debug import router as debug_router
34
from api.routers.download import router as download_router
45
from api.routers.health import router as health_router
56
from api.routers.libraries import router as libraries_router
@@ -12,6 +13,7 @@
1213

1314

1415
__all__ = [
16+
"debug_router",
1517
"download_router",
1618
"health_router",
1719
"libraries_router",

api/routers/debug.py

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""Debug endpoints for internal monitoring."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from datetime import datetime, timezone
7+
8+
from fastapi import APIRouter, Depends, Request
9+
from pydantic import BaseModel
10+
from sqlalchemy.ext.asyncio import AsyncSession
11+
12+
from api.dependencies import require_db
13+
from core.constants import SUPPORTED_LIBRARIES
14+
from core.database import SpecRepository
15+
16+
17+
router = APIRouter(prefix="/debug", tags=["debug"])
18+
19+
20+
# ============================================================================
21+
# Response Models
22+
# ============================================================================
23+
24+
25+
class SpecStatusItem(BaseModel):
26+
"""Status for a single specification with library scores."""
27+
28+
id: str
29+
title: str
30+
updated: str | None
31+
avg_score: float | None = None
32+
# Library scores - None means no implementation
33+
altair: float | None = None
34+
bokeh: float | None = None
35+
highcharts: float | None = None
36+
letsplot: float | None = None
37+
matplotlib: float | None = None
38+
plotly: float | None = None
39+
plotnine: float | None = None
40+
pygal: float | None = None
41+
seaborn: float | None = None
42+
43+
44+
class LibraryStats(BaseModel):
45+
"""Statistics for a single library."""
46+
47+
id: str
48+
name: str
49+
impl_count: int
50+
avg_score: float | None
51+
min_score: float | None
52+
max_score: float | None
53+
54+
55+
class ProblemSpec(BaseModel):
56+
"""A spec with issues."""
57+
58+
id: str
59+
title: str
60+
issue: str # Description of the problem
61+
value: str | None = None # Optional value (e.g., score, date)
62+
63+
64+
class SystemHealth(BaseModel):
65+
"""System health information."""
66+
67+
database_connected: bool
68+
api_response_time_ms: float
69+
timestamp: str
70+
total_specs_in_db: int
71+
total_impls_in_db: int
72+
73+
74+
class DebugStatusResponse(BaseModel):
75+
"""Debug dashboard data."""
76+
77+
# Summary
78+
total_specs: int
79+
total_implementations: int
80+
coverage_percent: float
81+
82+
# Library statistics
83+
library_stats: list[LibraryStats]
84+
85+
# Problem areas
86+
low_score_specs: list[ProblemSpec] # Specs with avg score < 85
87+
oldest_specs: list[ProblemSpec] # 10 oldest specs
88+
missing_preview_specs: list[ProblemSpec] # Specs with missing GCS images
89+
missing_tags_specs: list[ProblemSpec] # Specs without tags
90+
91+
# System health
92+
system: SystemHealth
93+
94+
# All specs for table
95+
specs: list[SpecStatusItem]
96+
97+
98+
# ============================================================================
99+
# Endpoint
100+
# ============================================================================
101+
102+
103+
@router.get("/status", response_model=DebugStatusResponse)
104+
async def get_debug_status(request: Request, db: AsyncSession = Depends(require_db)) -> DebugStatusResponse:
105+
"""
106+
Get comprehensive debug dashboard data.
107+
108+
Includes:
109+
- All specs with quality scores per library
110+
- Library statistics (avg/min/max scores, coverage)
111+
- Problem specs (low scores, old, missing data)
112+
- System health info
113+
"""
114+
start_time = time.time()
115+
116+
repo = SpecRepository(db)
117+
all_specs = await repo.get_all()
118+
119+
# ========================================================================
120+
# Build specs list and collect statistics
121+
# ========================================================================
122+
123+
specs_status: list[SpecStatusItem] = []
124+
total_implementations = 0
125+
126+
# Library aggregates
127+
library_scores: dict[str, list[float]] = {lib: [] for lib in SUPPORTED_LIBRARIES}
128+
library_counts: dict[str, int] = dict.fromkeys(SUPPORTED_LIBRARIES, 0) # type: ignore[arg-type]
129+
130+
# Problem tracking
131+
missing_preview: list[ProblemSpec] = []
132+
missing_tags: list[ProblemSpec] = []
133+
134+
for spec in all_specs:
135+
# Build library score map for this spec
136+
spec_scores: dict[str, float | None] = dict.fromkeys(SUPPORTED_LIBRARIES, None)
137+
spec_score_values: list[float] = []
138+
139+
for impl in spec.impls:
140+
lib_id = impl.library_id
141+
score = impl.quality_score
142+
143+
spec_scores[lib_id] = score
144+
total_implementations += 1
145+
library_counts[lib_id] += 1
146+
147+
if score is not None:
148+
library_scores[lib_id].append(score)
149+
spec_score_values.append(score)
150+
151+
# Check for missing preview
152+
if not impl.preview_url:
153+
missing_preview.append(ProblemSpec(id=spec.id, title=spec.title, issue=f"Missing preview for {lib_id}"))
154+
155+
# Calculate average score for this spec
156+
avg_score = sum(spec_score_values) / len(spec_score_values) if spec_score_values else None
157+
158+
# Find most recent update
159+
timestamps = [spec.updated] if spec.updated else []
160+
timestamps.extend(impl.updated for impl in spec.impls if impl.updated)
161+
most_recent = max(timestamps) if timestamps else None
162+
163+
# Check for missing tags
164+
if not spec.tags or not any(spec.tags.values()):
165+
missing_tags.append(ProblemSpec(id=spec.id, title=spec.title, issue="No tags defined"))
166+
167+
specs_status.append(
168+
SpecStatusItem(
169+
id=spec.id,
170+
title=spec.title,
171+
updated=most_recent.isoformat() if most_recent else None,
172+
avg_score=round(avg_score, 1) if avg_score else None,
173+
altair=spec_scores.get("altair"),
174+
bokeh=spec_scores.get("bokeh"),
175+
highcharts=spec_scores.get("highcharts"),
176+
letsplot=spec_scores.get("letsplot"),
177+
matplotlib=spec_scores.get("matplotlib"),
178+
plotly=spec_scores.get("plotly"),
179+
plotnine=spec_scores.get("plotnine"),
180+
pygal=spec_scores.get("pygal"),
181+
seaborn=spec_scores.get("seaborn"),
182+
)
183+
)
184+
185+
# Sort by updated (most recent first)
186+
specs_status.sort(key=lambda s: (s.updated or "", s.id), reverse=True)
187+
188+
# ========================================================================
189+
# Library Statistics
190+
# ========================================================================
191+
192+
library_names = {
193+
"altair": "Altair",
194+
"bokeh": "Bokeh",
195+
"highcharts": "Highcharts",
196+
"letsplot": "lets-plot",
197+
"matplotlib": "Matplotlib",
198+
"plotly": "Plotly",
199+
"plotnine": "plotnine",
200+
"pygal": "Pygal",
201+
"seaborn": "Seaborn",
202+
}
203+
204+
lib_stats: list[LibraryStats] = []
205+
for lib_id in sorted(SUPPORTED_LIBRARIES):
206+
scores = library_scores[lib_id]
207+
lib_stats.append(
208+
LibraryStats(
209+
id=lib_id,
210+
name=library_names.get(lib_id, lib_id),
211+
impl_count=library_counts[lib_id],
212+
avg_score=round(sum(scores) / len(scores), 1) if scores else None,
213+
min_score=round(min(scores), 1) if scores else None,
214+
max_score=round(max(scores), 1) if scores else None,
215+
)
216+
)
217+
218+
# Sort by impl_count descending
219+
lib_stats.sort(key=lambda x: x.impl_count, reverse=True)
220+
221+
# ========================================================================
222+
# Problem Specs
223+
# ========================================================================
224+
225+
# Low score specs (avg < 85)
226+
low_score_specs: list[ProblemSpec] = []
227+
for spec in specs_status:
228+
if spec.avg_score is not None and spec.avg_score < 85:
229+
low_score_specs.append(
230+
ProblemSpec(id=spec.id, title=spec.title, issue="Low average score", value=f"{spec.avg_score:.1f}")
231+
)
232+
low_score_specs.sort(key=lambda x: float(x.value or 0)) # Lowest first
233+
234+
# Oldest specs (by updated timestamp)
235+
specs_by_age = sorted(specs_status, key=lambda s: s.updated or "")
236+
oldest_specs: list[ProblemSpec] = []
237+
for spec in specs_by_age[:10]: # 10 oldest
238+
if spec.updated:
239+
try:
240+
dt = datetime.fromisoformat(spec.updated.replace("Z", "+00:00"))
241+
# Ensure dt is timezone-aware
242+
if dt.tzinfo is None:
243+
dt = dt.replace(tzinfo=timezone.utc)
244+
age_days = (datetime.now(timezone.utc) - dt).days
245+
oldest_specs.append(
246+
ProblemSpec(id=spec.id, title=spec.title, issue="Old spec", value=f"{age_days} days ago")
247+
)
248+
except ValueError:
249+
pass
250+
251+
# ========================================================================
252+
# System Health
253+
# ========================================================================
254+
255+
response_time_ms = (time.time() - start_time) * 1000
256+
coverage = (total_implementations / (len(all_specs) * 9) * 100) if all_specs else 0
257+
258+
system_health = SystemHealth(
259+
database_connected=True,
260+
api_response_time_ms=round(response_time_ms, 2),
261+
timestamp=datetime.now(timezone.utc).isoformat(),
262+
total_specs_in_db=len(all_specs),
263+
total_impls_in_db=total_implementations,
264+
)
265+
266+
# ========================================================================
267+
# Return Response
268+
# ========================================================================
269+
270+
return DebugStatusResponse(
271+
total_specs=len(all_specs),
272+
total_implementations=total_implementations,
273+
coverage_percent=round(coverage, 1),
274+
library_stats=lib_stats,
275+
low_score_specs=low_score_specs[:20], # Limit to 20
276+
oldest_specs=oldest_specs,
277+
missing_preview_specs=missing_preview[:20], # Limit to 20
278+
missing_tags_specs=missing_tags[:20], # Limit to 20
279+
system=system_health,
280+
specs=specs_status,
281+
)

app/src/components/Header.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { memo, useState, useEffect } from 'react';
1+
import { memo, useState, useEffect, useRef } from 'react';
2+
import { useNavigate } from 'react-router-dom';
23
import Box from '@mui/material/Box';
34
import Typography from '@mui/material/Typography';
4-
import Link from '@mui/material/Link';
55
import Tooltip from '@mui/material/Tooltip';
66
import ClickAwayListener from '@mui/material/ClickAwayListener';
77
import useMediaQuery from '@mui/material/useMediaQuery';
@@ -15,10 +15,13 @@ interface HeaderProps {
1515

1616
export const Header = memo(function Header({ stats, onRandom }: HeaderProps) {
1717
const theme = useTheme();
18+
const navigate = useNavigate();
1819
const isXs = useMediaQuery(theme.breakpoints.down('sm'));
1920
const isSm = useMediaQuery(theme.breakpoints.between('sm', 'md'));
2021
const [tooltipOpen, setTooltipOpen] = useState(false);
2122
const [pinned, setPinned] = useState(false); // true = opened via click, stays open
23+
const clickCountRef = useRef(0);
24+
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2225
const tooltipText = stats
2326
? `${stats.plots} plots across ${stats.libraries} libraries`
2427
: '';
@@ -56,16 +59,26 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) {
5659
fontSize: { xs: '2rem', sm: '2.75rem', md: '3.75rem' },
5760
}}
5861
>
59-
<Link
60-
href="https://pyplots.ai"
61-
target="_blank"
62-
rel="noopener noreferrer"
63-
underline="none"
62+
<Box
63+
component="span"
64+
onClick={(e) => {
65+
e.preventDefault();
66+
clickCountRef.current += 1;
67+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
68+
clickTimerRef.current = setTimeout(() => {
69+
clickCountRef.current = 0;
70+
}, 400);
71+
if (clickCountRef.current >= 3) {
72+
clickCountRef.current = 0;
73+
navigate('/debug');
74+
}
75+
}}
76+
sx={{ cursor: 'pointer', userSelect: 'none' }}
6477
>
6578
<Box component="span" sx={{ color: '#3776AB' }}>py</Box>
6679
<Box component="span" sx={{ color: '#FFD43B' }}>plots</Box>
6780
<Box component="span" sx={{ color: '#1f2937' }}>.ai</Box>
68-
</Link>
81+
</Box>
6982
{onRandom && (
7083
<ShuffleIcon
7184
tabIndex={0}

0 commit comments

Comments
 (0)