Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

CAPY Interactions Dashboard — a full-stack telemetry/observability dashboard for the CAPY Discord bot. The Discord bot sends interaction events to the backend, which stores them in PostgreSQL and serves aggregated metrics to a React frontend.

## Commands

### Full Stack (Docker)
```bash
docker compose up --build # Start all services (PostgreSQL, backend, frontend)
docker compose down # Stop all services
```

### Backend (FastAPI)
```bash
cd backend
uv sync # Install dependencies
uv run task start # Run dev server on :8000 (--reload)
```

### Frontend (React + Vite)
```bash
cd frontend
npm install
npm run dev # Dev server on :5173 (proxies /api → localhost:8000)
npm run build # Production build
```

## Architecture

**Data flow:** Discord bot → `POST /api/v1/telemetry/batch` → PostgreSQL → 7 GET endpoints → React dashboard (polls every 2s)

### Backend (`backend/dashboard/`)
- `main.py` — FastAPI app, CORS, mounts `telemetry` router at `/api/v1`, health check at `/health`
- `routers/telemetry.py` — All 8 endpoints: batch ingest (202), plus read endpoints for metrics, commands, timeseries, errors, interaction types, heatmap, recent events
- `database.py` — psycopg2 queries against `telemetry_interactions` and `telemetry_completions` tables; masks user IDs to last 4 digits
- `config.py` — Pydantic settings; `use_mock` env var toggles between real DB and mock data
- `mock_data.py` — Static fallback data used when `USE_MOCK=true`
- `models.py` — All Pydantic request/response models

### Frontend (`frontend/src/`)
- `pages/Dashboard.jsx` — Orchestrates all data fetching (7 parallel API calls), holds all state, manages the 2s auto-refresh interval; silent failure on refresh preserves stale data
- `api/telemetry.js` — Axios instance (baseURL `/api/v1`, 10s timeout); exports `fetchMetrics`, `fetchCommands`, `fetchTimeseries`, `fetchErrors`, `fetchInteractionTypes`, `fetchRecent`, `fetchHeatmap`
- `components/` — One component per chart/widget: `MetricCard`, `TimeSeriesChart`, `CommandTable`, `ErrorBreakdown`, `InteractionTypeChart`, `UsageHeatmap`, `ActivityFeed`, `Header`

### Infrastructure
- `docker-compose.yml` — Three services on `capy-net`: postgres (:5432), backend (:8000), frontend (:80 via Nginx)
- `init/02-grants.sql` — DB grants applied at container init
- `frontend/vite.config.js` — `/api` proxy to `http://localhost:8000` for local dev
- `frontend/tailwind.config.js` — Custom dark theme palette (primary `#0f1117`, card `#161b27`) and fonts (Inter, JetBrains Mono)

### Time Range Filtering
All read endpoints accept a `range` query param (`24h`, `7d`, `30d`). `Dashboard.jsx` holds a single `range` state that is passed to every fetch call and triggers a re-fetch on change.

### Mock Mode
Set `USE_MOCK=true` in `backend/.env` to bypass PostgreSQL entirely — the backend serves data from `mock_data.py`. Useful for frontend development without a running database.
8 changes: 8 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.venv/
__pycache__/
**/*.py[cod]
.env
Dockerfile
.dockerignore
.idea/
1 change: 0 additions & 1 deletion backend/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
USE_MOCK=false
DATABASE_URL=postgresql://capy:capy@localhost:5432/capy_dev
TELEMETRY_API_KEY=a4f8c2e1b9d3e7f05a6c2b8d4e0f1a3b
27 changes: 27 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
ARG python_version=3.11-slim
ARG uv_version=0.9.10

FROM ghcr.io/astral-sh/uv:${uv_version} AS uv

FROM python:${python_version} AS builder
COPY --from=uv /uv /bin/

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

WORKDIR /app

RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-dev --no-install-project

FROM python:${python_version}

COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"

WORKDIR /app
COPY dashboard/ ./dashboard/

CMD ["uvicorn", "dashboard.main:app", "--host", "0.0.0.0", "--port", "8000"]
Binary file modified backend/dashboard/__pycache__/database.cpython-311.pyc
Binary file not shown.
Binary file modified backend/dashboard/__pycache__/mock_data.cpython-311.pyc
Binary file not shown.
Binary file modified backend/dashboard/__pycache__/models.cpython-311.pyc
Binary file not shown.
1 change: 0 additions & 1 deletion backend/dashboard/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
class Settings(BaseSettings):
database_url: str = "postgresql://capy:capy@localhost:5432/capy_dev"
use_mock: bool = True
telemetry_api_key: str = "" # empty = auth disabled (dev/mock only)

class Config:
env_file = ".env"
Expand Down
22 changes: 22 additions & 0 deletions backend/dashboard/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CommandStat,
CompletionEventIn,
ErrorStat,
HeatmapPoint,
InteractionEventIn,
InteractionTypeStat,
MetricSummary,
Expand Down Expand Up @@ -229,6 +230,27 @@ def get_timeseries(range_days: int) -> list[TimeSeriesPoint]:
]


def get_heatmap(range_days: int) -> list[HeatmapPoint]:
query = """
SELECT
EXTRACT(DOW FROM timestamp)::int AS dow,
EXTRACT(HOUR FROM timestamp)::int AS hour,
COUNT(*) AS count
FROM telemetry_interactions
WHERE timestamp > NOW() - (%s || ' days')::INTERVAL
GROUP BY dow, hour
ORDER BY dow, hour
"""
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(query, (str(range_days),))
rows = cur.fetchall()
return [
HeatmapPoint(dow=int(r["dow"]), hour=int(r["hour"]), count=int(r["count"]))
for r in rows
]


def get_errors(range_days: int) -> list[ErrorStat]:
query = """
SELECT error_type, COUNT(*) AS count
Expand Down
16 changes: 16 additions & 0 deletions backend/dashboard/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from dashboard.models import (
CommandStat,
ErrorStat,
HeatmapPoint,
InteractionTypeStat,
MetricSummary,
RecentEvent,
Expand Down Expand Up @@ -226,6 +227,21 @@ def get_recent() -> list[RecentEvent]:
]


def get_heatmap(days: int) -> list[HeatmapPoint]:
records = _filter(days)
counts: dict[tuple[int, int], int] = {}
for r in records:
# Python weekday(): 0=Mon, 6=Sun → convert to Postgres DOW: 0=Sun, 6=Sat
dow = (r["timestamp"].weekday() + 1) % 7
hour = r["timestamp"].hour
key = (dow, hour)
counts[key] = counts.get(key, 0) + 1
return [
HeatmapPoint(dow=dow, hour=hour, count=count)
for (dow, hour), count in sorted(counts.items())
]


def get_errors(days: int) -> list[ErrorStat]:
records = _filter(days)
counts: dict[str, int] = {}
Expand Down
6 changes: 6 additions & 0 deletions backend/dashboard/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ class RecentEvent(BaseModel):
status: str | None
duration_ms: float | None
error_type: str | None


class HeatmapPoint(BaseModel):
dow: int # 0=Sunday, 6=Saturday (Postgres EXTRACT(DOW) convention)
hour: int # 0-23 UTC
count: int
Binary file modified backend/dashboard/routers/__pycache__/telemetry.cpython-311.pyc
Binary file not shown.
51 changes: 26 additions & 25 deletions backend/dashboard/routers/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import logging

import psycopg2
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import APIRouter, Query

log = logging.getLogger(__name__)

from dashboard import database, mock_data
from dashboard.config import settings
from dashboard.models import (
BatchRequest,
CommandStat,
ErrorStat,
HeatmapPoint,
InteractionTypeStat,
MetricSummary,
RecentEvent,
Expand All @@ -18,21 +22,12 @@

_RANGE_MAP = {"24h": 1, "7d": 7, "30d": 30}

security = HTTPBearer(auto_error=False)


def _range_days(range_str: str) -> int:
return _RANGE_MAP.get(range_str, 7)


def verify_api_key(credentials: HTTPAuthorizationCredentials = Security(security)):
if not settings.telemetry_api_key:
return # auth disabled in dev
if not credentials or credentials.credentials != settings.telemetry_api_key:
raise HTTPException(status_code=401, detail="Invalid API key")


@router.post("/telemetry/batch", status_code=202, dependencies=[Depends(verify_api_key)])
@router.post("/telemetry/batch", status_code=202)
def post_batch(body: BatchRequest):
if not body.events:
return {"written": 0}
Expand All @@ -43,19 +38,17 @@ def post_batch(body: BatchRequest):
written = 0
errors = []

try:
with database.get_connection() as conn:
for idx, event in enumerate(body.events):
try:
if event.type == "interaction":
database.insert_interaction(conn, event)
else:
database.insert_completion(conn, event)
written += 1
except Exception as exc:
errors.append({"index": idx, "reason": str(exc)})
except psycopg2.Error as exc:
return {"written": 0, "rejected": len(body.events), "errors": [{"index": 0, "reason": str(exc)}]}
for idx, event in enumerate(body.events):
try:
with database.get_connection() as conn:
if event.type == "interaction":
database.insert_interaction(conn, event)
else:
database.insert_completion(conn, event)
written += 1
except Exception as exc:
log.error("Failed to insert event %d (type=%s): %s", idx, event.type, exc)
errors.append({"index": idx, "reason": str(exc)})

response = {"written": written}
if errors:
Expand Down Expand Up @@ -109,3 +102,11 @@ def get_interaction_types(range: str = Query("7d", description="Time range: 24h,
if settings.use_mock:
return mock_data.get_interaction_types(days)
return database.get_interaction_types(days)


@router.get("/heatmap", response_model=list[HeatmapPoint])
def get_heatmap(range: str = Query("7d", description="Time range: 24h, 7d, or 30d")):
days = _range_days(range)
if settings.use_mock:
return mock_data.get_heatmap(days)
return database.get_heatmap(days)
52 changes: 52 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
services:

postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: capy
POSTGRES_PASSWORD: capy
POSTGRES_DB: capy_dev
volumes:
- postgres-data:/var/lib/postgresql/data
- ../discord-bot/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./init/02-grants.sql:/docker-entrypoint-initdb.d/02-grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U capy -d capy_dev"]
interval: 5s
timeout: 5s
retries: 10
networks:
- capy-net

backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql://capy:capy@postgres:5432/capy_dev
USE_MOCK: "false"
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- capy-net

frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
networks:
- capy-net

networks:
capy-net:
driver: bridge

volumes:
postgres-data:
6 changes: 6 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
node_modules/
dist/
Dockerfile
.dockerignore
.idea/
12 changes: 12 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
36 changes: 36 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript;
gzip_min_length 1024;

location /api/ {
resolver 127.0.0.11 valid=30s;
set $backend http://backend:8000;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}

location = /index.html {
add_header Cache-Control "no-cache";
}

location / {
try_files $uri $uri/ /index.html;
}
}
4 changes: 4 additions & 0 deletions frontend/src/api/telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ export function fetchInteractionTypes(range) {
export function fetchRecent() {
return api.get('/recent').then((r) => r.data)
}

export function fetchHeatmap(range) {
return api.get('/heatmap', { params: { range } }).then((r) => r.data)
}
Loading