Skip to content

Commit 5f3089b

Browse files
feat: add hidden debug dashboard (#3574)
## Summary - Add `/debug` route with comprehensive status overview for internal monitoring - Hidden access via triple-click on "pyplots.ai" logo (Easter Egg) - Read-only, no authentication required ## Features - **Stats**: Spec/implementation counts, coverage percentage - **Library Coverage**: Cards showing impl count, avg/min/max scores per library - **Problem Areas**: Accordions for low scores (<85), missing previews, missing tags, oldest specs - **System Health**: Database status, API response time - **Filters**: Search (ID/title), incomplete (<9), low scores (<90), missing library dropdown - **Table**: Sortable by ID, title, avg score, updated date. Color-coded quality scores ## Test plan - [ ] Visit `/debug` directly - [ ] Triple-click on logo from homepage → navigates to debug - [ ] Verify library stats cards show data - [ ] Test filters (search, checkboxes, dropdown) - [ ] Click spec ID → navigates to spec page - [ ] Click score → navigates to implementation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent de3121e commit 5f3089b

7 files changed

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

0 commit comments

Comments
 (0)