From 358b25af8affbb173490eeb45c18daefad82fff6 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 2 Feb 2026 18:39:06 -0800 Subject: [PATCH 1/6] Add dashboard frontend with public metrics endpoints - Add public query functions (no tokens/costs/models exposed) - Create /metrics/public/* endpoints (no auth required) - Build /dashboard page with Chart.js, community tabs, admin unlock - Register new routers in main.py - Add tests for public endpoints and dashboard page (28 new tests) --- src/api/main.py | 18 +- src/api/routers/__init__.py | 8 +- src/api/routers/dashboard.py | 821 ++++++++++++++++++++++++++ src/api/routers/metrics_public.py | 91 +++ src/metrics/queries.py | 168 ++++++ tests/test_api/test_dashboard.py | 73 +++ tests/test_api/test_metrics_public.py | 253 ++++++++ 7 files changed, 1429 insertions(+), 3 deletions(-) create mode 100644 src/api/routers/dashboard.py create mode 100644 src/api/routers/metrics_public.py create mode 100644 tests/test_api/test_dashboard.py create mode 100644 tests/test_api/test_metrics_public.py diff --git a/src/api/main.py b/src/api/main.py index 2dcc139..f0c40fd 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -14,7 +14,13 @@ from pydantic import BaseModel from src.api.config import get_settings -from src.api.routers import create_community_router, metrics_router, sync_router +from src.api.routers import ( + create_community_router, + metrics_public_router, + metrics_router, + sync_router, +) +from src.api.routers.dashboard import router as dashboard_router from src.api.routers.health import router as health_router from src.api.routers.widget_test import router as widget_test_router from src.api.scheduler import start_scheduler, stop_scheduler @@ -194,8 +200,12 @@ def register_routes(app: FastAPI) -> None: # Sync router (not community-specific) app.include_router(sync_router) - # Metrics router (global metrics endpoints) + # Metrics routers (admin + public) app.include_router(metrics_router) + app.include_router(metrics_public_router) + + # Dashboard + app.include_router(dashboard_router) # Health check router app.include_router(health_router) @@ -246,6 +256,10 @@ async def root() -> dict[str, Any]: endpoints["POST /sync/trigger"] = "Trigger sync (requires API key)" endpoints["GET /metrics/overview"] = "Metrics overview (requires admin key)" endpoints["GET /metrics/tokens"] = "Token breakdown (requires admin key)" + endpoints["GET /metrics/public/overview"] = "Public metrics overview" + endpoints["GET /metrics/public/{community_id}"] = "Public community summary" + endpoints["GET /metrics/public/{community_id}/usage"] = "Public usage stats" + endpoints["GET /dashboard"] = "Community dashboard" endpoints["GET /health"] = "Health check" return { diff --git a/src/api/routers/__init__.py b/src/api/routers/__init__.py index c2a7692..39f85ab 100644 --- a/src/api/routers/__init__.py +++ b/src/api/routers/__init__.py @@ -2,6 +2,12 @@ from src.api.routers.community import create_community_router from src.api.routers.metrics import router as metrics_router +from src.api.routers.metrics_public import router as metrics_public_router from src.api.routers.sync import router as sync_router -__all__ = ["create_community_router", "metrics_router", "sync_router"] +__all__ = [ + "create_community_router", + "metrics_public_router", + "metrics_router", + "sync_router", +] diff --git a/src/api/routers/dashboard.py b/src/api/routers/dashboard.py new file mode 100644 index 0000000..f4f97b0 --- /dev/null +++ b/src/api/routers/dashboard.py @@ -0,0 +1,821 @@ +"""Community dashboard page. + +Single-page dashboard served via FastAPI HTMLResponse that showcases +community activity with public data and optional admin-only sections. +Uses Chart.js via CDN for charts. +""" + +import logging + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Dashboard"]) + +DASHBOARD_HTML = """ + + + + + Open Science Assistant - Community Dashboard + + + + +
+
+

Open Science Assistant

+

Community Dashboard

+
+ + +
+

Overview

+
Loading metrics...
+
+ + +
+

Communities

+
+
+
+ + +
+

Admin Access

+

+ Enter admin API key to view token usage, costs, and model details. +

+
+ + + +
+
+
+ + + +""" + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard() -> str: + """Serve the community dashboard page. + + Returns an HTML page with: + - Overview cards (total questions, active communities, success rate) + - Community tabs with per-community charts + - Time period toggle (daily/weekly/monthly) + - Admin key input to unlock sensitive sections + """ + return DASHBOARD_HTML diff --git a/src/api/routers/metrics_public.py b/src/api/routers/metrics_public.py new file mode 100644 index 0000000..885a9fa --- /dev/null +++ b/src/api/routers/metrics_public.py @@ -0,0 +1,91 @@ +"""Public metrics API endpoints. + +Exposes non-sensitive community activity data (request counts, error rates, +top tools) without authentication. No tokens, costs, or model information. +""" + +import logging +import sqlite3 +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from src.metrics.db import get_metrics_connection +from src.metrics.queries import ( + get_public_community_summary, + get_public_overview, + get_public_usage_stats, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/metrics/public", tags=["Public Metrics"]) + + +@router.get("/overview") +async def public_overview() -> dict[str, Any]: + """Get public metrics overview across all communities. + + Returns total requests, error rate, active community count, + and per-community request counts. No tokens, costs, or model info. + """ + conn = get_metrics_connection() + try: + return get_public_overview(conn) + except sqlite3.Error: + logger.exception("Failed to query metrics database for public overview") + raise HTTPException( + status_code=503, + detail="Metrics database is temporarily unavailable.", + ) + finally: + conn.close() + + +@router.get("/{community_id}") +async def public_community_summary(community_id: str) -> dict[str, Any]: + """Get public summary for a specific community. + + Returns request counts, error rate, and top tools. + No tokens, costs, or model info. + """ + conn = get_metrics_connection() + try: + return get_public_community_summary(community_id, conn) + except sqlite3.Error: + logger.exception("Failed to query metrics database for community %s", community_id) + raise HTTPException( + status_code=503, + detail="Metrics database is temporarily unavailable.", + ) + finally: + conn.close() + + +@router.get("/{community_id}/usage") +async def public_community_usage( + community_id: str, + period: str = Query( + default="daily", + pattern="^(daily|weekly|monthly)$", + description="Time bucket period", + ), +) -> dict[str, Any]: + """Get public time-bucketed usage for a community. + + Returns request counts and errors per time bucket. + No tokens or costs. + """ + conn = get_metrics_connection() + try: + return get_public_usage_stats(community_id, period, conn) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except sqlite3.Error: + logger.exception("Failed to query metrics database for community %s usage", community_id) + raise HTTPException( + status_code=503, + detail="Metrics database is temporarily unavailable.", + ) + finally: + conn.close() diff --git a/src/metrics/queries.py b/src/metrics/queries.py index ec67e0a..6cda6f0 100644 --- a/src/metrics/queries.py +++ b/src/metrics/queries.py @@ -288,3 +288,171 @@ def get_token_breakdown( for r in by_key_source ], } + + +# --------------------------------------------------------------------------- +# Public query functions (no tokens, costs, or model info) +# --------------------------------------------------------------------------- + + +def get_public_overview(conn: sqlite3.Connection) -> dict[str, Any]: + """Get public metrics overview with only non-sensitive data. + + Returns request counts and error rates; no tokens, costs, or model info. + + Args: + conn: SQLite connection. + + Returns: + Dict with total_requests, error_rate, communities_active, + and per-community request counts. + """ + totals = conn.execute( + """ + SELECT + COUNT(*) as total_requests, + COUNT(CASE WHEN status_code >= 400 THEN 1 END) as total_errors + FROM request_log + """ + ).fetchone() + + total_req = totals["total_requests"] + + community_rows = conn.execute( + """ + SELECT + community_id, + COUNT(*) as requests, + COUNT(CASE WHEN status_code >= 400 THEN 1 END) as errors + FROM request_log + WHERE community_id IS NOT NULL + GROUP BY community_id + ORDER BY requests DESC + """ + ).fetchall() + + return { + "total_requests": total_req, + "error_rate": round(totals["total_errors"] / total_req, 4) if total_req > 0 else 0.0, + "communities_active": len(community_rows), + "communities": [ + { + "community_id": r["community_id"], + "requests": r["requests"], + "error_rate": round(r["errors"] / r["requests"], 4) if r["requests"] > 0 else 0.0, + } + for r in community_rows + ], + } + + +def get_public_community_summary(community_id: str, conn: sqlite3.Connection) -> dict[str, Any]: + """Get public summary for a single community. + + Returns request counts and top tools; no tokens, costs, or model info. + + Args: + community_id: The community identifier. + conn: SQLite connection. + + Returns: + Dict with community_id, total_requests, error_rate, top_tools. + """ + row = conn.execute( + """ + SELECT + COUNT(*) as total_requests, + COUNT(CASE WHEN status_code >= 400 THEN 1 END) as error_count + FROM request_log + WHERE community_id = ? + """, + (community_id,), + ).fetchone() + + total = row["total_requests"] + error_rate = row["error_count"] / total if total > 0 else 0.0 + + # Top tools (from JSON array in tools_called column) + tool_rows = conn.execute( + """ + SELECT tools_called + FROM request_log + WHERE community_id = ? AND tools_called IS NOT NULL + """, + (community_id,), + ).fetchall() + tool_counts: dict[str, int] = {} + for tr in tool_rows: + try: + tools = json.loads(tr["tools_called"]) + for tool in tools: + tool_counts[tool] = tool_counts.get(tool, 0) + 1 + except (json.JSONDecodeError, TypeError): + logger.warning( + "Malformed tools_called data in request_log for community %s: %r", + community_id, + tr["tools_called"], + ) + top_tools = sorted(tool_counts.items(), key=lambda x: x[1], reverse=True)[:5] + + return { + "community_id": community_id, + "total_requests": total, + "error_rate": round(error_rate, 4), + "top_tools": [{"tool": t[0], "count": t[1]} for t in top_tools], + } + + +def get_public_usage_stats( + community_id: str, + period: str, + conn: sqlite3.Connection, +) -> dict[str, Any]: + """Get public time-bucketed usage statistics. + + Returns request counts and errors per bucket; no tokens or costs. + + Args: + community_id: The community identifier. + period: One of "daily", "weekly", "monthly". + conn: SQLite connection. + + Returns: + Dict with period, community_id, and buckets list. + """ + format_map = { + "daily": "%Y-%m-%d", + "weekly": "%Y-W%W", + "monthly": "%Y-%m", + } + if period not in format_map: + raise ValueError(f"Invalid period: {period}. Must be one of: daily, weekly, monthly") + + fmt = format_map[period] + + rows = conn.execute( + f""" + SELECT + strftime('{fmt}', timestamp) as bucket, + COUNT(*) as requests, + COUNT(CASE WHEN status_code >= 400 THEN 1 END) as errors + FROM request_log + WHERE community_id = ? + GROUP BY bucket + ORDER BY bucket + """, + (community_id,), + ).fetchall() + + return { + "community_id": community_id, + "period": period, + "buckets": [ + { + "bucket": r["bucket"], + "requests": r["requests"], + "errors": r["errors"], + } + for r in rows + ], + } diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py new file mode 100644 index 0000000..a81cf65 --- /dev/null +++ b/tests/test_api/test_dashboard.py @@ -0,0 +1,73 @@ +"""Tests for the dashboard HTML page.""" + +import os + +import pytest +from fastapi.testclient import TestClient + +from src.api.main import app + + +@pytest.fixture +def client() -> TestClient: + """Create test client with auth disabled.""" + from src.api.config import get_settings + + os.environ["REQUIRE_API_AUTH"] = "false" + get_settings.cache_clear() + yield TestClient(app) + del os.environ["REQUIRE_API_AUTH"] + get_settings.cache_clear() + + +class TestDashboardPage: + """Tests for GET /dashboard.""" + + def test_returns_200(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert response.status_code == 200 + + def test_returns_html_content_type(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "text/html" in response.headers["content-type"] + + def test_contains_page_title(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "Open Science Assistant" in response.text + assert "Community Dashboard" in response.text + + def test_contains_chart_js_cdn(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "chart.js" in response.text + + def test_contains_overview_section(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "overviewContent" in response.text + assert "Questions Answered" in response.text or "Loading metrics" in response.text + + def test_contains_community_tabs(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "tabBar" in response.text + + def test_contains_admin_input(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "adminKeyInput" in response.text + assert "Admin Access" in response.text + + def test_contains_period_toggle_logic(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "changePeriod" in response.text + assert "daily" in response.text + assert "weekly" in response.text + assert "monthly" in response.text + + def test_contains_public_metrics_api_calls(self, client: TestClient) -> None: + response = client.get("/dashboard") + assert "/metrics/public/overview" in response.text + assert "/metrics/public/" in response.text + + def test_admin_section_hidden_by_default(self, client: TestClient) -> None: + response = client.get("/dashboard") + # Admin section has display:none by default, shown via JS + assert "admin-section" in response.text + assert "display: none" in response.text or "display:none" in response.text diff --git a/tests/test_api/test_metrics_public.py b/tests/test_api/test_metrics_public.py new file mode 100644 index 0000000..0866479 --- /dev/null +++ b/tests/test_api/test_metrics_public.py @@ -0,0 +1,253 @@ +"""Tests for public metrics API endpoints.""" + +import os +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from src.api.main import app +from src.metrics.db import ( + RequestLogEntry, + init_metrics_db, + log_request, +) + + +@pytest.fixture +def metrics_db(tmp_path): + """Create isolated metrics database with sample data.""" + db_path = tmp_path / "metrics.db" + init_metrics_db(db_path) + + entries = [ + RequestLogEntry( + request_id="r1", + timestamp="2025-01-15T10:00:00+00:00", + endpoint="/hed/ask", + method="POST", + community_id="hed", + duration_ms=200.0, + status_code=200, + model="qwen/qwen3-235b", + input_tokens=100, + output_tokens=50, + total_tokens=150, + estimated_cost=0.001, + tools_called=["search_docs", "validate_hed"], + key_source="platform", + ), + RequestLogEntry( + request_id="r2", + timestamp="2025-01-15T11:00:00+00:00", + endpoint="/hed/chat", + method="POST", + community_id="hed", + duration_ms=300.0, + status_code=200, + model="qwen/qwen3-235b", + input_tokens=200, + output_tokens=100, + total_tokens=300, + tools_called=["search_docs"], + key_source="byok", + ), + RequestLogEntry( + request_id="r3", + timestamp="2025-01-16T09:00:00+00:00", + endpoint="/hed/ask", + method="POST", + community_id="hed", + duration_ms=100.0, + status_code=500, + ), + RequestLogEntry( + request_id="r4", + timestamp="2025-01-15T12:00:00+00:00", + endpoint="/bids/ask", + method="POST", + community_id="bids", + duration_ms=250.0, + status_code=200, + model="anthropic/claude-sonnet", + input_tokens=150, + output_tokens=75, + total_tokens=225, + key_source="platform", + ), + ] + for e in entries: + log_request(e, db_path=db_path) + + return db_path + + +@pytest.fixture +def isolated_metrics(metrics_db): + """Patch metrics DB path for all metrics code.""" + with patch("src.metrics.db.get_metrics_db_path", return_value=metrics_db): + yield metrics_db + + +@pytest.fixture +def noauth_env(): + """Disable auth requirement.""" + from src.api.config import get_settings + + os.environ["REQUIRE_API_AUTH"] = "false" + get_settings.cache_clear() + yield + del os.environ["REQUIRE_API_AUTH"] + get_settings.cache_clear() + + +@pytest.fixture +def client(): + """Create test client.""" + return TestClient(app) + + +class TestPublicOverview: + """Tests for GET /metrics/public/overview.""" + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_returns_200_without_auth(self, client): + response = client.get("/metrics/public/overview") + assert response.status_code == 200 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_response_structure(self, client): + response = client.get("/metrics/public/overview") + data = response.json() + assert "total_requests" in data + assert "error_rate" in data + assert "communities_active" in data + assert "communities" in data + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_no_sensitive_fields(self, client): + response = client.get("/metrics/public/overview") + data = response.json() + assert "total_tokens" not in data + assert "total_estimated_cost" not in data + assert "avg_duration_ms" not in data + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_request_counts_correct(self, client): + response = client.get("/metrics/public/overview") + data = response.json() + assert data["total_requests"] == 4 + assert data["communities_active"] == 2 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_error_rate_includes_errors(self, client): + response = client.get("/metrics/public/overview") + data = response.json() + # 1 error out of 4 requests = 0.25 + assert data["error_rate"] == 0.25 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_community_breakdown_no_sensitive_fields(self, client): + response = client.get("/metrics/public/overview") + data = response.json() + for community in data["communities"]: + assert "community_id" in community + assert "requests" in community + assert "error_rate" in community + assert "tokens" not in community + assert "estimated_cost" not in community + + +class TestPublicCommunitySummary: + """Tests for GET /metrics/public/{community_id}.""" + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_returns_200_without_auth(self, client): + response = client.get("/metrics/public/hed") + assert response.status_code == 200 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_response_structure(self, client): + response = client.get("/metrics/public/hed") + data = response.json() + assert data["community_id"] == "hed" + assert "total_requests" in data + assert "error_rate" in data + assert "top_tools" in data + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_no_sensitive_fields(self, client): + response = client.get("/metrics/public/hed") + data = response.json() + assert "total_tokens" not in data + assert "total_estimated_cost" not in data + assert "top_models" not in data + assert "avg_duration_ms" not in data + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_top_tools_populated(self, client): + response = client.get("/metrics/public/hed") + data = response.json() + tools = {t["tool"]: t["count"] for t in data["top_tools"]} + assert "search_docs" in tools + assert tools["search_docs"] == 2 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_unknown_community_returns_empty(self, client): + response = client.get("/metrics/public/nonexistent") + assert response.status_code == 200 + data = response.json() + assert data["total_requests"] == 0 + + +class TestPublicCommunityUsage: + """Tests for GET /metrics/public/{community_id}/usage.""" + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_daily_usage_returns_200(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + assert response.status_code == 200 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_daily_usage_structure(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + data = response.json() + assert data["community_id"] == "hed" + assert data["period"] == "daily" + assert "buckets" in data + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_buckets_no_sensitive_fields(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + data = response.json() + for bucket in data["buckets"]: + assert "bucket" in bucket + assert "requests" in bucket + assert "errors" in bucket + assert "tokens" not in bucket + assert "estimated_cost" not in bucket + assert "avg_duration_ms" not in bucket + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_monthly_usage(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "monthly"}) + assert response.status_code == 200 + data = response.json() + assert data["period"] == "monthly" + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_weekly_usage(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "weekly"}) + assert response.status_code == 200 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_invalid_period_returns_422(self, client): + response = client.get("/metrics/public/hed/usage", params={"period": "hourly"}) + assert response.status_code == 422 + + @pytest.mark.usefixtures("isolated_metrics", "noauth_env") + def test_default_period_is_daily(self, client): + response = client.get("/metrics/public/hed/usage") + assert response.status_code == 200 + data = response.json() + assert data["period"] == "daily" From a64f8c37e7a17a2926fc26c29de4cb614ab43b81 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 2 Feb 2026 19:32:07 -0800 Subject: [PATCH 2/6] Restructure dashboard as standalone static site - Move per-community public metrics to community router (/{community_id}/metrics/public, /{community_id}/metrics/public/usage) - Keep only global /metrics/public/overview in metrics_public router - Remove FastAPI dashboard router - Add dashboard/ as standalone static site for Cloudflare Pages: / = aggregate overview, /{community} = community detail - Client-side routing with configurable API base URL - Add _redirects for Cloudflare Pages SPA routing - Update tests for new route structure --- dashboard/_redirects | 1 + .../dashboard.py => dashboard/index.html | 612 +++++++----------- src/api/main.py | 9 +- src/api/routers/community.py | 61 +- src/api/routers/metrics_public.py | 64 +- tests/test_api/test_dashboard.py | 155 ++--- tests/test_api/test_metrics_public.py | 44 +- 7 files changed, 416 insertions(+), 530 deletions(-) create mode 100644 dashboard/_redirects rename src/api/routers/dashboard.py => dashboard/index.html (54%) diff --git a/dashboard/_redirects b/dashboard/_redirects new file mode 100644 index 0000000..bbb3e7a --- /dev/null +++ b/dashboard/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/src/api/routers/dashboard.py b/dashboard/index.html similarity index 54% rename from src/api/routers/dashboard.py rename to dashboard/index.html index f4f97b0..f4f7fc6 100644 --- a/src/api/routers/dashboard.py +++ b/dashboard/index.html @@ -1,32 +1,12 @@ -"""Community dashboard page. - -Single-page dashboard served via FastAPI HTMLResponse that showcases -community activity with public data and optional admin-only sections. -Uses Chart.js via CDN for charts. -""" - -import logging - -from fastapi import APIRouter -from fastapi.responses import HTMLResponse - -logger = logging.getLogger(__name__) - -router = APIRouter(tags=["Dashboard"]) - -DASHBOARD_HTML = """ + - Open Science Assistant - Community Dashboard + Open Science Assistant - Dashboard
-
+ +
Loading...
- -
-

Overview

-
Loading metrics...
-
- - -
-

Communities

-
-
-
- - -
+ +
-""" - - -@router.get("/dashboard", response_class=HTMLResponse) -async def dashboard() -> str: - """Serve the community dashboard page. - - Returns an HTML page with: - - Overview cards (total questions, active communities, success rate) - - Community tabs with per-community charts - - Time period toggle (daily/weekly/monthly) - - Admin key input to unlock sensitive sections - """ - return DASHBOARD_HTML + diff --git a/src/api/main.py b/src/api/main.py index f0c40fd..9d6566a 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -20,7 +20,6 @@ metrics_router, sync_router, ) -from src.api.routers.dashboard import router as dashboard_router from src.api.routers.health import router as health_router from src.api.routers.widget_test import router as widget_test_router from src.api.scheduler import start_scheduler, stop_scheduler @@ -204,9 +203,6 @@ def register_routes(app: FastAPI) -> None: app.include_router(metrics_router) app.include_router(metrics_public_router) - # Dashboard - app.include_router(dashboard_router) - # Health check router app.include_router(health_router) @@ -249,6 +245,8 @@ async def root() -> dict[str, Any]: endpoints[f"GET /{community_id}/sessions"] = f"List active {name} sessions" endpoints[f"GET /{community_id}/sessions/{{session_id}}"] = "Get session info" endpoints[f"DELETE /{community_id}/sessions/{{session_id}}"] = "Delete a session" + endpoints[f"GET /{community_id}/metrics/public"] = f"Public {name} metrics" + endpoints[f"GET /{community_id}/metrics/public/usage"] = f"Public {name} usage stats" # Add non-community endpoints endpoints["GET /sync/status"] = "Knowledge sync status" @@ -257,9 +255,6 @@ async def root() -> dict[str, Any]: endpoints["GET /metrics/overview"] = "Metrics overview (requires admin key)" endpoints["GET /metrics/tokens"] = "Token breakdown (requires admin key)" endpoints["GET /metrics/public/overview"] = "Public metrics overview" - endpoints["GET /metrics/public/{community_id}"] = "Public community summary" - endpoints["GET /metrics/public/{community_id}/usage"] = "Public usage stats" - endpoints["GET /dashboard"] = "Community dashboard" endpoints["GET /health"] = "Health check" return { diff --git a/src/api/routers/community.py b/src/api/routers/community.py index 4062ffd..baf8262 100644 --- a/src/api/routers/community.py +++ b/src/api/routers/community.py @@ -34,7 +34,12 @@ log_request, now_iso, ) -from src.metrics.queries import get_community_summary, get_usage_stats +from src.metrics.queries import ( + get_community_summary, + get_public_community_summary, + get_public_usage_stats, + get_usage_stats, +) logger = logging.getLogger(__name__) @@ -1094,6 +1099,60 @@ async def community_usage( finally: conn.close() + # ----------------------------------------------------------------------- + # Per-community Public Metrics Endpoints (no auth required) + # ----------------------------------------------------------------------- + + @router.get("/metrics/public") + async def community_metrics_public() -> dict[str, Any]: + """Get public metrics summary for this community. + + Returns request counts, error rate, and top tools. + No tokens, costs, or model information exposed. + """ + import sqlite3 + + conn = get_metrics_connection() + try: + return get_public_community_summary(community_id, conn) + except sqlite3.Error: + logger.exception("Failed to query public metrics for community %s", community_id) + raise HTTPException( + status_code=503, + detail="Metrics database is temporarily unavailable.", + ) + finally: + conn.close() + + @router.get("/metrics/public/usage") + async def community_usage_public( + period: str = Query( + default="daily", + description="Time bucket period", + pattern="^(daily|weekly|monthly)$", + ), + ) -> dict[str, Any]: + """Get public time-bucketed usage stats for this community. + + Returns request counts and errors per time bucket. + No tokens or costs exposed. + """ + import sqlite3 + + conn = get_metrics_connection() + try: + return get_public_usage_stats(community_id, period, conn) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except sqlite3.Error: + logger.exception("Failed to query public usage stats for community %s", community_id) + raise HTTPException( + status_code=503, + detail="Metrics database is temporarily unavailable.", + ) + finally: + conn.close() + return router diff --git a/src/api/routers/metrics_public.py b/src/api/routers/metrics_public.py index 885a9fa..9d1885c 100644 --- a/src/api/routers/metrics_public.py +++ b/src/api/routers/metrics_public.py @@ -1,21 +1,20 @@ """Public metrics API endpoints. -Exposes non-sensitive community activity data (request counts, error rates, -top tools) without authentication. No tokens, costs, or model information. +Exposes non-sensitive aggregate metrics (request counts, error rates) +without authentication. No tokens, costs, or model information. + +Per-community public metrics are served from the community router +at /{community_id}/metrics/public. """ import logging import sqlite3 from typing import Any -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException from src.metrics.db import get_metrics_connection -from src.metrics.queries import ( - get_public_community_summary, - get_public_overview, - get_public_usage_stats, -) +from src.metrics.queries import get_public_overview logger = logging.getLogger(__name__) @@ -40,52 +39,3 @@ async def public_overview() -> dict[str, Any]: ) finally: conn.close() - - -@router.get("/{community_id}") -async def public_community_summary(community_id: str) -> dict[str, Any]: - """Get public summary for a specific community. - - Returns request counts, error rate, and top tools. - No tokens, costs, or model info. - """ - conn = get_metrics_connection() - try: - return get_public_community_summary(community_id, conn) - except sqlite3.Error: - logger.exception("Failed to query metrics database for community %s", community_id) - raise HTTPException( - status_code=503, - detail="Metrics database is temporarily unavailable.", - ) - finally: - conn.close() - - -@router.get("/{community_id}/usage") -async def public_community_usage( - community_id: str, - period: str = Query( - default="daily", - pattern="^(daily|weekly|monthly)$", - description="Time bucket period", - ), -) -> dict[str, Any]: - """Get public time-bucketed usage for a community. - - Returns request counts and errors per time bucket. - No tokens or costs. - """ - conn = get_metrics_connection() - try: - return get_public_usage_stats(community_id, period, conn) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - except sqlite3.Error: - logger.exception("Failed to query metrics database for community %s usage", community_id) - raise HTTPException( - status_code=503, - detail="Metrics database is temporarily unavailable.", - ) - finally: - conn.close() diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index a81cf65..8ac51a0 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -1,73 +1,82 @@ -"""Tests for the dashboard HTML page.""" - -import os - -import pytest -from fastapi.testclient import TestClient - -from src.api.main import app - - -@pytest.fixture -def client() -> TestClient: - """Create test client with auth disabled.""" - from src.api.config import get_settings - - os.environ["REQUIRE_API_AUTH"] = "false" - get_settings.cache_clear() - yield TestClient(app) - del os.environ["REQUIRE_API_AUTH"] - get_settings.cache_clear() - - -class TestDashboardPage: - """Tests for GET /dashboard.""" - - def test_returns_200(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert response.status_code == 200 - - def test_returns_html_content_type(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "text/html" in response.headers["content-type"] - - def test_contains_page_title(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "Open Science Assistant" in response.text - assert "Community Dashboard" in response.text - - def test_contains_chart_js_cdn(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "chart.js" in response.text - - def test_contains_overview_section(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "overviewContent" in response.text - assert "Questions Answered" in response.text or "Loading metrics" in response.text - - def test_contains_community_tabs(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "tabBar" in response.text - - def test_contains_admin_input(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "adminKeyInput" in response.text - assert "Admin Access" in response.text - - def test_contains_period_toggle_logic(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "changePeriod" in response.text - assert "daily" in response.text - assert "weekly" in response.text - assert "monthly" in response.text - - def test_contains_public_metrics_api_calls(self, client: TestClient) -> None: - response = client.get("/dashboard") - assert "/metrics/public/overview" in response.text - assert "/metrics/public/" in response.text - - def test_admin_section_hidden_by_default(self, client: TestClient) -> None: - response = client.get("/dashboard") - # Admin section has display:none by default, shown via JS - assert "admin-section" in response.text - assert "display: none" in response.text or "display:none" in response.text +"""Tests for the dashboard static HTML page. + +The dashboard is a standalone static site in dashboard/index.html, +deployed separately to Cloudflare Pages. These tests verify the HTML +contains the expected structure and API references. +""" + +from pathlib import Path + +DASHBOARD_HTML_PATH = Path(__file__).parent.parent.parent / "dashboard" / "index.html" + + +class TestDashboardHTML: + """Tests for dashboard/index.html static file.""" + + def test_file_exists(self) -> None: + assert DASHBOARD_HTML_PATH.exists(), "dashboard/index.html must exist" + + def test_is_valid_html(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "" in content + assert "" in content + + def test_contains_page_title(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "Open Science Assistant" in content + + def test_contains_chart_js_cdn(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "chart.js" in content + + def test_references_public_overview_api(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "/metrics/public/overview" in content + + def test_references_community_public_metrics_api(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + # Should use /{community}/metrics/public pattern + assert "/metrics/public" in content + assert "/metrics/public/usage" in content + + def test_has_client_side_routing(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "getRoute" in content + assert "window.location.pathname" in content + + def test_has_aggregate_view(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "loadAggregateView" in content + assert "Questions Answered" in content + + def test_has_community_view(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "loadCommunityView" in content + assert "Back to all communities" in content + + def test_has_admin_key_input(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "adminKeyInput" in content + assert "Admin Access" in content + + def test_admin_section_hidden_by_default(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "admin-section" in content + assert "display: none" in content or "display:none" in content + + def test_has_period_toggle(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "changePeriod" in content + assert "daily" in content + assert "weekly" in content + assert "monthly" in content + + def test_api_base_configurable(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + # Should support ?api= query param or window.OSA_API_BASE override + assert "OSA_API_BASE" in content + + def test_cloudflare_redirects_file_exists(self) -> None: + redirects_path = DASHBOARD_HTML_PATH.parent / "_redirects" + assert redirects_path.exists(), "_redirects needed for Cloudflare Pages SPA routing" diff --git a/tests/test_api/test_metrics_public.py b/tests/test_api/test_metrics_public.py index 0866479..3a86d1a 100644 --- a/tests/test_api/test_metrics_public.py +++ b/tests/test_api/test_metrics_public.py @@ -1,4 +1,9 @@ -"""Tests for public metrics API endpoints.""" +"""Tests for public metrics API endpoints. + +Global overview: GET /metrics/public/overview (no auth) +Per-community: GET /{community_id}/metrics/public (no auth) +Per-community usage: GET /{community_id}/metrics/public/usage (no auth) +""" import os from unittest.mock import patch @@ -158,17 +163,17 @@ def test_community_breakdown_no_sensitive_fields(self, client): assert "estimated_cost" not in community -class TestPublicCommunitySummary: - """Tests for GET /metrics/public/{community_id}.""" +class TestCommunityPublicMetrics: + """Tests for GET /{community_id}/metrics/public.""" @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_returns_200_without_auth(self, client): - response = client.get("/metrics/public/hed") + response = client.get("/hed/metrics/public") assert response.status_code == 200 @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_response_structure(self, client): - response = client.get("/metrics/public/hed") + response = client.get("/hed/metrics/public") data = response.json() assert data["community_id"] == "hed" assert "total_requests" in data @@ -177,7 +182,7 @@ def test_response_structure(self, client): @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_no_sensitive_fields(self, client): - response = client.get("/metrics/public/hed") + response = client.get("/hed/metrics/public") data = response.json() assert "total_tokens" not in data assert "total_estimated_cost" not in data @@ -186,31 +191,24 @@ def test_no_sensitive_fields(self, client): @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_top_tools_populated(self, client): - response = client.get("/metrics/public/hed") + response = client.get("/hed/metrics/public") data = response.json() tools = {t["tool"]: t["count"] for t in data["top_tools"]} assert "search_docs" in tools assert tools["search_docs"] == 2 - @pytest.mark.usefixtures("isolated_metrics", "noauth_env") - def test_unknown_community_returns_empty(self, client): - response = client.get("/metrics/public/nonexistent") - assert response.status_code == 200 - data = response.json() - assert data["total_requests"] == 0 - -class TestPublicCommunityUsage: - """Tests for GET /metrics/public/{community_id}/usage.""" +class TestCommunityPublicUsage: + """Tests for GET /{community_id}/metrics/public/usage.""" @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_daily_usage_returns_200(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + response = client.get("/hed/metrics/public/usage", params={"period": "daily"}) assert response.status_code == 200 @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_daily_usage_structure(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + response = client.get("/hed/metrics/public/usage", params={"period": "daily"}) data = response.json() assert data["community_id"] == "hed" assert data["period"] == "daily" @@ -218,7 +216,7 @@ def test_daily_usage_structure(self, client): @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_buckets_no_sensitive_fields(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "daily"}) + response = client.get("/hed/metrics/public/usage", params={"period": "daily"}) data = response.json() for bucket in data["buckets"]: assert "bucket" in bucket @@ -230,24 +228,24 @@ def test_buckets_no_sensitive_fields(self, client): @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_monthly_usage(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "monthly"}) + response = client.get("/hed/metrics/public/usage", params={"period": "monthly"}) assert response.status_code == 200 data = response.json() assert data["period"] == "monthly" @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_weekly_usage(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "weekly"}) + response = client.get("/hed/metrics/public/usage", params={"period": "weekly"}) assert response.status_code == 200 @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_invalid_period_returns_422(self, client): - response = client.get("/metrics/public/hed/usage", params={"period": "hourly"}) + response = client.get("/hed/metrics/public/usage", params={"period": "hourly"}) assert response.status_code == 422 @pytest.mark.usefixtures("isolated_metrics", "noauth_env") def test_default_period_is_daily(self, client): - response = client.get("/metrics/public/hed/usage") + response = client.get("/hed/metrics/public/usage") assert response.status_code == 200 data = response.json() assert data["period"] == "daily" From 1d34691d3c301dd56e874773c761612bda632aef Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 2 Feb 2026 20:57:09 -0800 Subject: [PATCH 3/6] Add CI workflow for dashboard Cloudflare Pages deploy Deploys dashboard/ to osa-dash.pages.dev via wrangler. Same pattern as existing deploy-pages.yml for the demo widget: - main -> osa-dash.pages.dev (production) - develop -> develop.osa-dash.pages.dev - PRs -> {branch}.osa-dash.pages.dev with preview URL comment --- .github/workflows/deploy-dashboard.yml | 104 +++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/deploy-dashboard.yml diff --git a/.github/workflows/deploy-dashboard.yml b/.github/workflows/deploy-dashboard.yml new file mode 100644 index 0000000..cc4ad16 --- /dev/null +++ b/.github/workflows/deploy-dashboard.yml @@ -0,0 +1,104 @@ +name: Deploy Dashboard to Cloudflare Pages + +on: + push: + branches: + - main + - develop + paths: + - 'dashboard/**' + - '.github/workflows/deploy-dashboard.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'dashboard/**' + - '.github/workflows/deploy-dashboard.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get branch name + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BRANCH_NAME="${{ github.head_ref }}" + else + BRANCH_NAME="${{ github.ref_name }}" + fi + # Sanitize branch name for URL (replace / with -, lowercase) + SANITIZED=$(echo "$BRANCH_NAME" | tr '/' '-' | tr '[:upper:]' '[:lower:]') + # Cloudflare Pages truncates branch names to ~28 chars for preview URLs + TRUNCATED=$(echo "$SANITIZED" | cut -c1-28) + echo "name=$TRUNCATED" >> $GITHUB_OUTPUT + echo "original=$BRANCH_NAME" >> $GITHUB_OUTPUT + + - name: Deploy to Cloudflare Pages + id: deploy + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Deploy with branch name so Cloudflare Pages routes correctly: + # - main branch -> osa-dash.pages.dev (production) + # - develop branch -> develop.osa-dash.pages.dev (preview) + # - PR branches -> {branch}.osa-dash.pages.dev (preview) + command: pages deploy dashboard --project-name=osa-dash --branch=${{ steps.branch.outputs.name }} + + - name: Comment on PR with preview URL + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const branch = '${{ steps.branch.outputs.name }}'; + const previewUrl = `https://${branch}.osa-dash.pages.dev`; + + // Find existing comment from this workflow + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('') + ); + + const sha = '${{ github.sha }}'.substring(0, 7); + const body = [ + '', + '## Dashboard Preview', + '', + '| Name | Link |', + '|------|------|', + `| **Preview URL** | ${previewUrl} |`, + '| **Branch** | \`${{ steps.branch.outputs.original }}\` |', + `| **Commit** | \`${sha}\` |`, + '', + 'This preview will be updated automatically when you push new commits.' + ].join('\n'); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } From 7bbe90e3d6d495609cb1e7ab6690427d96af2257 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 2 Feb 2026 21:08:18 -0800 Subject: [PATCH 4/6] Add dynamic community tab bar to dashboard Tabs are populated from /metrics/public/overview API so new communities appear automatically. Navigation uses simple links (All -> /, community -> /{id}) with active tab highlighting. --- dashboard/index.html | 90 ++++++++++++++++++++++++-------- tests/test_api/test_dashboard.py | 9 +++- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index f4f7fc6..5b543d6 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -100,6 +100,36 @@ } .community-card .stats strong { color: #374151; } + /* Tab bar */ + .tab-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + } + .tab-link { + display: inline-block; + padding: 0.6rem 1.2rem; + border: 2px solid rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.15); + color: white; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; + transition: all 0.2s; + } + .tab-link:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.6); + } + .tab-link.active { + background: white; + color: #667eea; + border-color: white; + } + /* Period toggle */ .period-toggle { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .period-btn { @@ -235,6 +265,7 @@

Open Science Assistant

Community Dashboard

+
Loading...
@@ -271,6 +302,7 @@

Admin Access

// ----------------------------------------------------------------------- let adminKey = null; let activePeriod = 'daily'; + let overviewData = null; let usageChartInstance = null; let toolsChartInstance = null; let adminTokenChartInstance = null; @@ -285,38 +317,57 @@

Admin Access

// Router - read path to decide view // ----------------------------------------------------------------------- function getRoute() { - // Strip trailing slash and leading slash const path = window.location.pathname.replace(/^\/+|\/+$/g, ''); if (!path) return { view: 'aggregate', community: null }; return { view: 'community', community: path.split('/')[0] }; } - document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('DOMContentLoaded', async () => { + const app = document.getElementById('app'); const route = getRoute(); + + // Always fetch overview first to populate tabs + try { + const resp = await fetch(`${API_BASE}/metrics/public/overview`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + overviewData = await resp.json(); + renderTabs(overviewData.communities, route.community); + } catch (err) { + // Tabs fail gracefully; continue to load the view + } + if (route.view === 'community') { loadCommunityView(route.community); } else { - loadAggregateView(); + if (overviewData) { + renderAggregateView(overviewData); + document.getElementById('adminCard').style.display = ''; + } else { + app.className = ''; + app.innerHTML = '
Failed to load metrics.
'; + } } }); // ----------------------------------------------------------------------- - // Aggregate View (root /) + // Tabs // ----------------------------------------------------------------------- - async function loadAggregateView() { - const app = document.getElementById('app'); - try { - const resp = await fetch(`${API_BASE}/metrics/public/overview`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - renderAggregateView(data); - document.getElementById('adminCard').style.display = ''; - } catch (err) { - app.className = ''; - app.innerHTML = `
Failed to load metrics: ${err.message}
`; + function renderTabs(communities, activeCommunity) { + const tabBar = document.getElementById('tabBar'); + if (!communities || communities.length === 0) return; + + let html = `All`; + for (const c of communities) { + const isActive = c.community_id === activeCommunity; + html += `${c.community_id.toUpperCase()}`; } + tabBar.innerHTML = html; } + // ----------------------------------------------------------------------- + // Aggregate View (root /) + // ----------------------------------------------------------------------- + function renderAggregateView(data) { const app = document.getElementById('app'); const successRate = data.total_requests > 0 @@ -337,7 +388,7 @@

${c.community_id.toUpperCase()}

`; }).join(''); } else { - communityCards = '

No community data yet.

'; + communityCards = '

No community data yet. Select a community tab above.

'; } app.className = ''; @@ -371,13 +422,6 @@

Communities

// ----------------------------------------------------------------------- async function loadCommunityView(communityId) { const app = document.getElementById('app'); - const header = document.getElementById('pageHeader'); - - // Update header with back link - header.innerHTML = ` -

${communityId.toUpperCase()}

-

Back to all communities

- `; document.title = `${communityId.toUpperCase()} - OSA Dashboard`; try { diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 8ac51a0..d15b00d 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -47,13 +47,18 @@ def test_has_client_side_routing(self) -> None: def test_has_aggregate_view(self) -> None: content = DASHBOARD_HTML_PATH.read_text() - assert "loadAggregateView" in content + assert "renderAggregateView" in content assert "Questions Answered" in content def test_has_community_view(self) -> None: content = DASHBOARD_HTML_PATH.read_text() assert "loadCommunityView" in content - assert "Back to all communities" in content + + def test_has_tab_bar(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "tabBar" in content + assert "tab-link" in content + assert "renderTabs" in content def test_has_admin_key_input(self) -> None: content = DASHBOARD_HTML_PATH.read_text() From 523c42762b73eb10a4ceb15aec2214a05cf98c65 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 2 Feb 2026 21:32:20 -0800 Subject: [PATCH 5/6] Address PR review findings: XSS, error handling, tests - Fix XSS: add escapeHtml() helper, sanitize all innerHTML interpolations, use encodeURIComponent() for URL path segments - Move get_metrics_connection() inside try blocks in all metrics endpoints - Add console.error/warn to all JavaScript catch blocks (no silent failures) - Improve admin section UX: defer visibility until data loads successfully - Extract shared helpers in queries.py (_count_tools, _validate_period) - Add test classes: TestPublicAdminBoundary, TestEmptyDatabase, TestCommunityMetricsValues with dynamic community cross-checks - Fix admin boundary tests to use auth_env fixture with test API key --- dashboard/index.html | 67 +++++++++----- src/api/routers/community.py | 36 ++++---- src/api/routers/metrics_public.py | 9 +- src/metrics/queries.py | 127 ++++++++++++-------------- tests/test_api/test_metrics_public.py | 126 +++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 113 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 5b543d6..2510929 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -268,7 +268,7 @@

Open Science Assistant

Loading...
- +