From 7b30880d448aadb32e8084fc8ee28e0816173da2 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 26 Mar 2026 12:54:13 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20CC=20Dashboard?= =?UTF-8?q?=20v1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant Claude Code monitoring dashboard. FastAPI + PostgreSQL + Docker + SSE real-time updates. Montserrat font, black/#FFC407 color scheme. Apache reverse proxy config at /cc-dashboard/. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 15 + .gitignore | 53 +-- Dockerfile | 14 + alembic/alembic.ini | 38 +++ alembic/env.py | 54 ++++ alembic/versions/0001_initial.py | 107 ++++++ apache.conf | 24 ++ docker-compose.yml | 30 ++ requirements.txt | 11 + scripts/create_admin.py | 43 +++ src/__init__.py | 0 src/auth.py | 109 +++++++ src/config.py | 30 ++ src/database.py | 22 ++ src/main.py | 49 +++ src/models.py | 106 ++++++ src/routers/__init__.py | 0 src/routers/admin.py | 82 +++++ src/routers/auth.py | 60 ++++ src/routers/dashboard.py | 370 +++++++++++++++++++++ src/routers/events.py | 62 ++++ src/routers/ingest.py | 98 ++++++ src/routers/keys.py | 41 +++ src/routers/projects.py | 35 ++ src/schemas.py | 194 +++++++++++ src/services/__init__.py | 0 src/services/aggregator.py | 67 ++++ src/services/event_bus.py | 30 ++ src/static/collector/cc-collector.py | 322 ++++++++++++++++++ src/static/css/app.css | 448 ++++++++++++++++++++++++++ src/static/index.html | 31 ++ src/static/js/api.js | 131 ++++++++ src/static/js/app.js | 159 +++++++++ src/static/js/charts.js | 234 ++++++++++++++ src/static/js/pages/admin.js | 110 +++++++ src/static/js/pages/dashboard.js | 172 ++++++++++ src/static/js/pages/keys.js | 150 +++++++++ src/static/js/pages/live.js | 62 ++++ src/static/js/pages/project-detail.js | 86 +++++ src/static/js/pages/projects.js | 48 +++ src/static/js/pages/settings.js | 91 ++++++ src/static/js/sse.js | 61 ++++ 42 files changed, 3810 insertions(+), 39 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 alembic/alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/versions/0001_initial.py create mode 100644 apache.conf create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scripts/create_admin.py create mode 100644 src/__init__.py create mode 100644 src/auth.py create mode 100644 src/config.py create mode 100644 src/database.py create mode 100644 src/main.py create mode 100644 src/models.py create mode 100644 src/routers/__init__.py create mode 100644 src/routers/admin.py create mode 100644 src/routers/auth.py create mode 100644 src/routers/dashboard.py create mode 100644 src/routers/events.py create mode 100644 src/routers/ingest.py create mode 100644 src/routers/keys.py create mode 100644 src/routers/projects.py create mode 100644 src/schemas.py create mode 100644 src/services/__init__.py create mode 100644 src/services/aggregator.py create mode 100644 src/services/event_bus.py create mode 100644 src/static/collector/cc-collector.py create mode 100644 src/static/css/app.css create mode 100644 src/static/index.html create mode 100644 src/static/js/api.js create mode 100644 src/static/js/app.js create mode 100644 src/static/js/charts.js create mode 100644 src/static/js/pages/admin.js create mode 100644 src/static/js/pages/dashboard.js create mode 100644 src/static/js/pages/keys.js create mode 100644 src/static/js/pages/live.js create mode 100644 src/static/js/pages/project-detail.js create mode 100644 src/static/js/pages/projects.js create mode 100644 src/static/js/pages/settings.js create mode 100644 src/static/js/sse.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cdeb70f --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Database +DB_PASSWORD=change_me_strong_password + +# Full DSN (auto-constructed from above in config.py, override if needed) +# DATABASE_URL=postgresql+asyncpg://cc_app:change_me@db:5432/cc_dashboard + +# JWT +SECRET_KEY=change_me_at_least_32_chars_long_random_string +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# App +BASE_PATH=/cc-dashboard +APP_TITLE=CC Dashboard +DEBUG=false diff --git a/.gitignore b/.gitignore index b24d71e..815c299 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,25 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files +.env +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.coverage +dist/ node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode -*.py[cod] - -# Log files -*.log - -# Package files *.jar - -# Maven -target/ -dist/ - -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS -.DS_Store - -# Generated by Windows -Thumbs.db - -# Applications +*.class +*.log *.app *.exe *.war - -# Large media files +.DS_Store +Thumbs.db +.idea/ +TEST*.xml *.mp4 *.tiff *.avi *.flv *.mov *.wmv - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0052e3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Run migrations then start server +CMD ["sh", "-c", "alembic upgrade head && uvicorn src.main:app --host 0.0.0.0 --port 8800 --workers 1"] diff --git a/alembic/alembic.ini b/alembic/alembic.ini new file mode 100644 index 0000000..27e668c --- /dev/null +++ b/alembic/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c7d0352 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,54 @@ +import asyncio +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.config import settings +from src.database import Base +import src.models # noqa — register all models + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + engine = create_async_engine(settings.DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync( + lambda conn: context.configure( + connection=conn, + target_metadata=target_metadata, + compare_type=True, + ) + ) + await conn.run_sync(lambda conn: context.run_migrations()) + await engine.dispose() + + +def run_migrations_online(): + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..7a42e67 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,107 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-03-26 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE TYPE user_role AS ENUM ('admin', 'user')") + + op.create_table( + "users", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("email", sa.String(255), nullable=False, unique=True), + sa.Column("username", sa.String(100), nullable=False, unique=True), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("role", sa.Enum("admin", "user", name="user_role"), nullable=False, server_default="user"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), + sa.Column("daily_overhead_hours", sa.Float, nullable=False, server_default="2.0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_users_email", "users", ["email"]) + + op.create_table( + "api_keys", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("key_hash", sa.String(255), nullable=False), + sa.Column("key_prefix", sa.String(12), nullable=False), + sa.Column("label", sa.String(100), server_default="My Machine"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) + op.create_index("ix_api_keys_prefix", "api_keys", ["key_prefix"]) + + op.create_table( + "projects", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("slug", sa.String(255), nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("root_path", sa.String(500), server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "slug", name="uq_project_user_slug"), + ) + op.create_index("ix_projects_user_id", "projects", ["user_id"]) + + op.create_table( + "sessions", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("project_id", UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("session_id", sa.String(100), nullable=False), + sa.Column("date", sa.Date, nullable=False), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("active_hours", sa.Float, server_default="0"), + sa.Column("message_count", sa.Integer, server_default="0"), + sa.Column("work_summary", sa.Text, server_default=""), + sa.Column("commits", JSONB, server_default="[]"), + sa.Column("tools_used", JSONB, server_default="{}"), + sa.Column("files_changed", JSONB, server_default="[]"), + sa.Column("raw_stats", JSONB, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "session_id", "date", name="uq_session_user_date"), + ) + op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) + op.create_index("ix_sessions_project_id", "sessions", ["project_id"]) + op.create_index("ix_sessions_date", "sessions", ["date"]) + + op.create_table( + "daily_stats", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("project_id", UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("date", sa.Date, nullable=False), + sa.Column("total_hours", sa.Float, server_default="0"), + sa.Column("session_count", sa.Integer, server_default="0"), + sa.Column("message_count", sa.Integer, server_default="0"), + sa.Column("commit_count", sa.Integer, server_default="0"), + sa.Column("files_changed_count", sa.Integer, server_default="0"), + sa.Column("top_tools", JSONB, server_default="{}"), + sa.UniqueConstraint("user_id", "project_id", "date", name="uq_daily_stat"), + ) + op.create_index("ix_daily_stats_user_id", "daily_stats", ["user_id"]) + op.create_index("ix_daily_stats_date", "daily_stats", ["date"]) + op.create_index("ix_daily_stats_project_id", "daily_stats", ["project_id"]) + + +def downgrade(): + op.drop_table("daily_stats") + op.drop_table("sessions") + op.drop_table("projects") + op.drop_table("api_keys") + op.drop_table("users") + op.execute("DROP TYPE user_role") diff --git a/apache.conf b/apache.conf new file mode 100644 index 0000000..9f02adb --- /dev/null +++ b/apache.conf @@ -0,0 +1,24 @@ +# CC Dashboard — Apache reverse proxy config +# Include this in your VirtualHost for optical-dev.oliver.solutions +# Requires: mod_proxy mod_proxy_http mod_headers + +# Enable required modules if not already: +# a2enmod proxy proxy_http headers + + + ProxyPreserveHost On + ProxyPass http://127.0.0.1:8800/cc-dashboard/ + ProxyPassReverse http://127.0.0.1:8800/cc-dashboard/ + + # Disable response buffering — critical for SSE + Header set X-Accel-Buffering "no" + SetEnv proxy-nokeepalive 1 + SetEnv proxy-sendchunked 1 + + # Allow enough time for SSE connections (heartbeat every 25s, so 60s is fine) + ProxyTimeout 60 + + # Forward real IP + RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s" + RequestHeader set X-Forwarded-Proto "https" + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..00a7725 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + app: + build: . + ports: + - "8800:8800" + env_file: .env + depends_on: + db: + condition: service_healthy + restart: unless-stopped + volumes: + - ./src/static:/app/src/static:ro # hot-reload static files without rebuild + + db: + image: postgres:16-alpine + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: cc_dashboard + POSTGRES_USER: cc_app + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cc_app -d cc_dashboard"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + pgdata: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a05e866 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 +pydantic[email]==2.10.3 +pydantic-settings==2.6.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +httpx==0.28.1 diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..c64216e --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Run inside Docker to create the first admin user. + docker compose exec app python scripts/create_admin.py +""" +import asyncio +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.auth import hash_password +from src.database import AsyncSessionLocal +from src.models import User +from sqlalchemy import select + + +async def main(): + email = input("Admin email: ").strip() + username = input("Username: ").strip() + password = input("Password (min 8 chars): ").strip() + + if len(password) < 8: + print("Password too short") + sys.exit(1) + + async with AsyncSessionLocal() as db: + existing = await db.execute(select(User).where(User.email == email)) + if existing.scalar_one_or_none(): + print(f"User {email} already exists") + sys.exit(1) + + user = User( + email=email, + username=username, + password_hash=hash_password(password), + role="admin", + ) + db.add(user) + await db.commit() + print(f"Admin created: {email}") + + +asyncio.run(main()) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..c6d50c2 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,109 @@ +import secrets +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import Depends, HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import settings +from src.database import get_db +from src.models import ApiKey, User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +bearer_scheme = HTTPBearer(auto_error=False) + +ALGORITHM = "HS256" + + +# ── Password ────────────────────────────────────────────────────────────────── + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +# ── JWT ─────────────────────────────────────────────────────────────────────── + +def create_access_token(user_id: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode( + {"sub": user_id, "role": role, "exp": expire, "type": "access"}, + settings.SECRET_KEY, algorithm=ALGORITHM, + ) + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + return jwt.encode( + {"sub": user_id, "exp": expire, "type": "refresh"}, + settings.SECRET_KEY, algorithm=ALGORITHM, + ) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +# ── API Key ─────────────────────────────────────────────────────────────────── + +def generate_api_key() -> tuple[str, str, str]: + """Returns (raw_key, prefix, hash). raw_key shown once to user.""" + raw = "cc_" + secrets.token_urlsafe(32) + prefix = raw[:11] # "cc_" + 8 chars + return raw, prefix, pwd_context.hash(raw) + + +async def verify_api_key(raw_key: str, db: AsyncSession) -> User | None: + """Find user by API key. Updates last_used_at.""" + if not raw_key or not raw_key.startswith("cc_"): + return None + prefix = raw_key[:11] + result = await db.execute( + select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active == True) + .join(ApiKey.user) + .where(User.is_active == True) + ) + keys = result.scalars().all() + for key in keys: + if pwd_context.verify(raw_key, key.key_hash): + key.last_used_at = datetime.now(timezone.utc) + await db.commit() + return key.user + return None + + +# ── FastAPI dependencies ────────────────────────────────────────────────────── + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)], + db: AsyncSession = Depends(get_db), +) -> User: + if not credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + payload = decode_token(credentials.credentials) + if payload.get("type") != "access": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + user = await db.get(User, payload["sub"]) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +async def get_admin_user(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] +AdminUser = Annotated[User, Depends(get_admin_user)] diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..f42cef6 --- /dev/null +++ b/src/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # DB + DB_PASSWORD: str = "postgres" + DATABASE_URL: str = "" + + # JWT + SECRET_KEY: str = "dev_secret_key_change_in_production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # App + BASE_PATH: str = "/cc-dashboard" + APP_TITLE: str = "CC Dashboard" + DEBUG: bool = False + + def model_post_init(self, __context): + if not self.DATABASE_URL: + object.__setattr__( + self, + "DATABASE_URL", + f"postgresql+asyncpg://cc_app:{self.DB_PASSWORD}@db:5432/cc_dashboard", + ) + + +settings = Settings() diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..96759b3 --- /dev/null +++ b/src/database.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from src.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=10, + max_overflow=20, + echo=settings.DEBUG, +) + +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f475a96 --- /dev/null +++ b/src/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from src.config import settings +from src.routers import admin, auth, dashboard, events, ingest, keys, projects + +BASE = settings.BASE_PATH # "/cc-dashboard" + +app = FastAPI( + title=settings.APP_TITLE, + docs_url=f"{BASE}/docs" if settings.DEBUG else None, + redoc_url=None, + root_path=BASE, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routers +for router in [auth.router, keys.router, admin.router, ingest.router, + dashboard.router, events.router, projects.router]: + app.include_router(router) + +# Static files — served at /cc-dashboard/static/ +app.mount(f"{BASE}/static", StaticFiles(directory="src/static"), name="static") + +# SPA fallback — serve index.html for all non-API routes +from fastapi.responses import FileResponse +from fastapi import Request + +@app.get(f"{BASE}/", include_in_schema=False) +@app.get(f"{BASE}", include_in_schema=False) +async def spa_root(): + return FileResponse("src/static/index.html") + + +@app.get(f"{BASE}/{{path:path}}", include_in_schema=False) +async def spa_fallback(path: str, request: Request): + # Don't catch API routes + if path.startswith("api/") or path.startswith("static/"): + from fastapi import HTTPException + raise HTTPException(status_code=404) + return FileResponse("src/static/index.html") diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..bf438dd --- /dev/null +++ b/src/models.py @@ -0,0 +1,106 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, Date, DateTime, Enum, Float, ForeignKey, + Integer, String, Text, UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from src.database import Base + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(Enum("admin", "user", name="user_role"), default="user", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + daily_overhead_hours: Mapped[float] = mapped_column(Float, default=2.0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="user", cascade="all, delete-orphan") + projects: Mapped[list["Project"]] = relationship(back_populates="user", cascade="all, delete-orphan") + sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid) + user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(255), nullable=False) + key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # "cc_XXXXXXXX" for display + label: Mapped[str] = mapped_column(String(100), default="My Machine") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User"] = relationship(back_populates="api_keys") + + +class Project(Base): + __tablename__ = "projects" + __table_args__ = (UniqueConstraint("user_id", "slug", name="uq_project_user_slug"),) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid) + user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + root_path: Mapped[str] = mapped_column(String(500), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User"] = relationship(back_populates="projects") + sessions: Mapped[list["Session"]] = relationship(back_populates="project", cascade="all, delete-orphan") + daily_stats: Mapped[list["DailyStat"]] = relationship(back_populates="project", cascade="all, delete-orphan") + + +class Session(Base): + __tablename__ = "sessions" + __table_args__ = (UniqueConstraint("user_id", "session_id", "date", name="uq_session_user_date"),) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid) + user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + project_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) + session_id: Mapped[str] = mapped_column(String(100), nullable=False) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + active_hours: Mapped[float] = mapped_column(Float, default=0.0) + message_count: Mapped[int] = mapped_column(Integer, default=0) + work_summary: Mapped[str] = mapped_column(Text, default="") + commits: Mapped[list] = mapped_column(JSONB, default=list) + tools_used: Mapped[dict] = mapped_column(JSONB, default=dict) + files_changed: Mapped[list] = mapped_column(JSONB, default=list) + raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User"] = relationship(back_populates="sessions") + project: Mapped["Project"] = relationship(back_populates="sessions") + + +class DailyStat(Base): + __tablename__ = "daily_stats" + __table_args__ = (UniqueConstraint("user_id", "project_id", "date", name="uq_daily_stat"),) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid) + user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + project_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + total_hours: Mapped[float] = mapped_column(Float, default=0.0) + session_count: Mapped[int] = mapped_column(Integer, default=0) + message_count: Mapped[int] = mapped_column(Integer, default=0) + commit_count: Mapped[int] = mapped_column(Integer, default=0) + files_changed_count: Mapped[int] = mapped_column(Integer, default=0) + top_tools: Mapped[dict] = mapped_column(JSONB, default=dict) + + project: Mapped["Project"] = relationship(back_populates="daily_stats") diff --git a/src/routers/__init__.py b/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/routers/admin.py b/src/routers/admin.py new file mode 100644 index 0000000..b73c5c6 --- /dev/null +++ b/src/routers/admin.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import AdminUser, hash_password +from src.database import get_db +from src.models import Session, User +from src.schemas import AdminStats, UserCreate, UserOut, UserUpdate + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +@router.get("/users", response_model=list[UserOut]) +async def list_users(admin: AdminUser, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).order_by(User.created_at.desc())) + return result.scalars().all() + + +@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED) +async def create_user(body: UserCreate, admin: AdminUser, db: AsyncSession = Depends(get_db)): + exists = await db.execute(select(User).where(User.email == body.email)) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + user = User( + email=body.email, + username=body.username, + password_hash=hash_password(body.password), + role=body.role, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.put("/users/{user_id}", response_model=UserOut) +async def update_user( + user_id: str, body: UserUpdate, admin: AdminUser, db: AsyncSession = Depends(get_db) +): + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if body.username is not None: + user.username = body.username + if body.role is not None: + user.role = body.role + if body.is_active is not None: + user.is_active = body.is_active + if body.daily_overhead_hours is not None: + user.daily_overhead_hours = body.daily_overhead_hours + await db.commit() + await db.refresh(user) + return user + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(user_id: str, admin: AdminUser, db: AsyncSession = Depends(get_db)): + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + user.is_active = False + await db.commit() + + +@router.get("/stats", response_model=AdminStats) +async def platform_stats(admin: AdminUser, db: AsyncSession = Depends(get_db)): + users_result = await db.execute(select(User).order_by(User.created_at.desc())) + users = users_result.scalars().all() + active_count = sum(1 for u in users if u.is_active) + + sessions_count = await db.scalar(select(func.count(Session.id))) + total_hours = await db.scalar(select(func.coalesce(func.sum(Session.active_hours), 0))) + + return AdminStats( + total_users=len(users), + active_users=active_count, + total_sessions=sessions_count or 0, + total_hours=round(float(total_hours or 0), 2), + users=[UserOut.model_validate(u) for u in users], + ) diff --git a/src/routers/auth.py b/src/routers/auth.py new file mode 100644 index 0000000..73495cc --- /dev/null +++ b/src/routers/auth.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import ( + CurrentUser, create_access_token, create_refresh_token, + decode_token, hash_password, verify_password, +) +from src.database import get_db +from src.models import User +from src.schemas import ( + ChangePasswordRequest, LoginRequest, RefreshRequest, + TokenResponse, UserOut, +) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == body.email)) + user = result.scalar_one_or_none() + if not user or not user.is_active or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + return TokenResponse( + access_token=create_access_token(user.id, user.role), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)): + payload = decode_token(body.refresh_token) + if payload.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + user = await db.get(User, payload["sub"]) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return TokenResponse( + access_token=create_access_token(user.id, user.role), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/change-password") +async def change_password( + body: ChangePasswordRequest, + user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + if not verify_password(body.current_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Wrong current password") + user.password_hash = hash_password(body.new_password) + await db.commit() + return {"detail": "Password changed"} + + +@router.get("/me", response_model=UserOut) +async def me(user: CurrentUser): + return user diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py new file mode 100644 index 0000000..56cc385 --- /dev/null +++ b/src/routers/dashboard.py @@ -0,0 +1,370 @@ +"""Dashboard aggregation endpoints.""" +from collections import defaultdict +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import CurrentUser +from src.database import get_db +from src.models import DailyStat, Project, Session +from src.schemas import ( + DailyPoint, DowPoint, KpiSummary, MonthlyPoint, + ProjectDetail, ProjectHours, ProjectOut, SessionOut, ToolUsage, +) + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + +DOW_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + +def _date_range(from_date: date | None, to_date: date | None): + if not to_date: + to_date = date.today() + if not from_date: + from_date = to_date - timedelta(days=29) + return from_date, to_date + + +@router.get("/summary", response_model=KpiSummary) +async def summary( + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + from_date, to_date = _date_range(from_date, to_date) + + stats_result = await db.execute( + select(DailyStat, Project.display_name) + .join(Project, DailyStat.project_id == Project.id) + .where( + DailyStat.user_id == user.id, + DailyStat.date >= from_date, + DailyStat.date <= to_date, + ) + ) + rows = stats_result.all() + + if not rows: + return KpiSummary( + total_hours=0, total_projects=0, working_days=0, + total_sessions=0, avg_hours_per_day=0, top_project="—", + total_commits=0, total_files_changed=0, + period_from=from_date, period_to=to_date, + ) + + # Apply overhead proportionally per day + day_totals: dict[date, float] = defaultdict(float) + for stat, _ in rows: + day_totals[stat.date] += stat.total_hours + + proj_hours: dict[str, float] = defaultdict(float) + total_hours = total_sessions = total_commits = total_files = 0 + working_days: set = set() + + for stat, proj_name in rows: + day_total = day_totals[stat.date] + share = stat.total_hours / day_total if day_total > 0 else 0 + overhead = user.daily_overhead_hours * share + adjusted = stat.total_hours + overhead + + proj_hours[proj_name] += adjusted + total_hours += adjusted + total_sessions += stat.session_count + total_commits += stat.commit_count + total_files += stat.files_changed_count + working_days.add(stat.date) + + top_project = max(proj_hours, key=proj_hours.get) if proj_hours else "—" + n_days = len(working_days) + + return KpiSummary( + total_hours=round(total_hours, 2), + total_projects=len(proj_hours), + working_days=n_days, + total_sessions=total_sessions, + avg_hours_per_day=round(total_hours / n_days, 2) if n_days else 0, + top_project=top_project, + total_commits=total_commits, + total_files_changed=total_files, + period_from=from_date, + period_to=to_date, + ) + + +@router.get("/projects", response_model=list[ProjectHours]) +async def projects_overview( + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + from_date, to_date = _date_range(from_date, to_date) + + result = await db.execute( + select(DailyStat, Project) + .join(Project, DailyStat.project_id == Project.id) + .where( + DailyStat.user_id == user.id, + DailyStat.date >= from_date, + DailyStat.date <= to_date, + ) + ) + rows = result.all() + + day_totals: dict[date, float] = defaultdict(float) + for stat, _ in rows: + day_totals[stat.date] += stat.total_hours + + proj_data: dict[str, dict] = {} + for stat, project in rows: + pid = project.id + if pid not in proj_data: + proj_data[pid] = { + "project_id": pid, + "display_name": project.display_name, + "total_hours": 0.0, + "session_count": 0, + "days": set(), + "last_active": None, + } + day_total = day_totals[stat.date] + share = stat.total_hours / day_total if day_total > 0 else 0 + proj_data[pid]["total_hours"] += stat.total_hours + user.daily_overhead_hours * share + proj_data[pid]["session_count"] += stat.session_count + proj_data[pid]["days"].add(stat.date) + if not proj_data[pid]["last_active"] or stat.date > proj_data[pid]["last_active"]: + proj_data[pid]["last_active"] = stat.date + + return sorted( + [ + ProjectHours( + project_id=v["project_id"], + display_name=v["display_name"], + total_hours=round(v["total_hours"], 2), + session_count=v["session_count"], + working_days=len(v["days"]), + last_active=v["last_active"], + ) + for v in proj_data.values() + ], + key=lambda x: -x.total_hours, + ) + + +@router.get("/timeline", response_model=list[DailyPoint]) +async def timeline( + user: CurrentUser, + days: int = Query(default=30, ge=7, le=365), + db: AsyncSession = Depends(get_db), +): + to_date = date.today() + from_date = to_date - timedelta(days=days - 1) + + result = await db.execute( + select(DailyStat.date, func.sum(DailyStat.total_hours), func.sum(DailyStat.session_count)) + .where( + DailyStat.user_id == user.id, + DailyStat.date >= from_date, + DailyStat.date <= to_date, + ) + .group_by(DailyStat.date) + .order_by(DailyStat.date) + ) + rows = result.all() + + # Build day map with overhead + day_map = {r[0]: (float(r[1] or 0), int(r[2] or 0)) for r in rows} + + # Get project counts per day for overhead distribution (simplified: add flat overhead per working day) + points = [] + for i in range(days): + d = from_date + timedelta(days=i) + raw_h, sess = day_map.get(d, (0.0, 0)) + if raw_h > 0: + # Get distinct project count for this day to distribute overhead + proj_count_result = await db.scalar( + select(func.count(func.distinct(DailyStat.project_id))) + .where(DailyStat.user_id == user.id, DailyStat.date == d) + ) + adjusted = raw_h + user.daily_overhead_hours if proj_count_result else raw_h + else: + adjusted = 0.0 + points.append(DailyPoint(date=d, hours=round(adjusted, 2), sessions=sess)) + + return points + + +@router.get("/monthly", response_model=list[MonthlyPoint]) +async def monthly(user: CurrentUser, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select( + func.to_char(DailyStat.date, "YYYY-MM").label("month"), + func.sum(DailyStat.total_hours), + ) + .where(DailyStat.user_id == user.id) + .group_by("month") + .order_by("month") + ) + rows = result.all() + + # Apply daily overhead (rough: count distinct working days per month) + month_days_result = await db.execute( + select( + func.to_char(DailyStat.date, "YYYY-MM").label("month"), + func.count(func.distinct(DailyStat.date)), + ) + .where(DailyStat.user_id == user.id) + .group_by("month") + ) + month_days = {r[0]: r[1] for r in month_days_result.all()} + + return [ + MonthlyPoint( + month=r[0], + hours=round(float(r[1] or 0) + user.daily_overhead_hours * month_days.get(r[0], 0), 2), + ) + for r in rows + ] + + +@router.get("/dow", response_model=list[DowPoint]) +async def day_of_week(user: CurrentUser, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select( + func.extract("dow", DailyStat.date).label("dow_pg"), # 0=Sun in PG + func.sum(DailyStat.total_hours), + ) + .where(DailyStat.user_id == user.id) + .group_by("dow_pg") + .order_by("dow_pg") + ) + rows = result.all() + # PG dow: 0=Sun, convert to Mon-based + pg_to_mon = {0: 6, 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5} + dow_map: dict[int, float] = defaultdict(float) + for pg_dow, hours in rows: + dow_map[pg_to_mon[int(pg_dow)]] += float(hours or 0) + return [DowPoint(dow=i, label=DOW_LABELS[i], hours=round(dow_map[i], 2)) for i in range(7)] + + +@router.get("/tools", response_model=list[ToolUsage]) +async def tools_usage( + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + from_date, to_date = _date_range(from_date, to_date) + result = await db.execute( + select(DailyStat.top_tools) + .where( + DailyStat.user_id == user.id, + DailyStat.date >= from_date, + DailyStat.date <= to_date, + ) + ) + combined: dict[str, int] = defaultdict(int) + for (tools,) in result.all(): + for tool, cnt in (tools or {}).items(): + combined[tool] += cnt + return sorted( + [ToolUsage(tool=t, count=c) for t, c in combined.items()], + key=lambda x: -x.count, + )[:15] + + +@router.get("/activity", response_model=list[SessionOut]) +async def activity( + user: CurrentUser, + activity_date: date | None = Query(default=None, alias="date"), + db: AsyncSession = Depends(get_db), +): + if not activity_date: + activity_date = date.today() + result = await db.execute( + select(Session, Project.display_name) + .join(Project, Session.project_id == Project.id) + .where(Session.user_id == user.id, Session.date == activity_date) + .order_by(Session.start_at) + ) + return [ + SessionOut( + id=s.id, session_id=s.session_id, project_id=s.project_id, + project_name=name, date=s.date, start_at=s.start_at, end_at=s.end_at, + active_hours=s.active_hours, message_count=s.message_count, + work_summary=s.work_summary, commits=s.commits or [], + tools_used=s.tools_used or {}, files_changed=s.files_changed or [], + ) + for s, name in result.all() + ] + + +@router.get("/project/{project_id}", response_model=ProjectDetail) +async def project_detail( + project_id: str, + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + project = await db.get(Project, project_id) + if not project or project.user_id != user.id: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Project not found") + + from_date, to_date = _date_range(from_date, to_date) + + stats_result = await db.execute( + select(DailyStat) + .where( + DailyStat.user_id == user.id, + DailyStat.project_id == project_id, + DailyStat.date >= from_date, + DailyStat.date <= to_date, + ) + .order_by(DailyStat.date) + ) + stats = stats_result.scalars().all() + + sessions_result = await db.execute( + select(Session) + .where( + Session.user_id == user.id, + Session.project_id == project_id, + Session.date >= from_date, + Session.date <= to_date, + ) + .order_by(Session.start_at.desc()) + .limit(50) + ) + sessions = sessions_result.scalars().all() + + # Top files + files_count: dict[str, int] = defaultdict(int) + tools_count: dict[str, int] = defaultdict(int) + for s in sessions: + for f in (s.files_changed or []): + files_count[f] += 1 + for t, c in (s.tools_used or {}).items(): + tools_count[t] += c + + return ProjectDetail( + project=ProjectOut.model_validate(project), + daily=[DailyPoint(date=s.date, hours=round(s.total_hours, 2), sessions=s.session_count) for s in stats], + sessions=[ + SessionOut( + id=s.id, session_id=s.session_id, project_id=s.project_id, + project_name=project.display_name, date=s.date, + start_at=s.start_at, end_at=s.end_at, active_hours=s.active_hours, + message_count=s.message_count, work_summary=s.work_summary, + commits=s.commits or [], tools_used=s.tools_used or {}, + files_changed=s.files_changed or [], + ) + for s in sessions + ], + top_files=sorted([{"file": f, "count": c} for f, c in files_count.items()], key=lambda x: -x["count"])[:10], + top_tools=sorted([ToolUsage(tool=t, count=c) for t, c in tools_count.items()], key=lambda x: -x.count)[:10], + ) diff --git a/src/routers/events.py b/src/routers/events.py new file mode 100644 index 0000000..c92f69a --- /dev/null +++ b/src/routers/events.py @@ -0,0 +1,62 @@ +"""SSE endpoint — heartbeat every 25s to survive 30s LB timeout.""" +import asyncio +import json + +from fastapi import APIRouter, Depends, Query, HTTPException, status +from fastapi.responses import StreamingResponse +from jose import JWTError, jwt +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import settings +from src.database import get_db +from src.models import User +from src.auth import CurrentUser, ALGORITHM +from src.services.event_bus import bus + +router = APIRouter(prefix="/api", tags=["events"]) + + +async def _get_user_from_token( + token: str = Query(...), + db: AsyncSession = Depends(get_db), +) -> User: + """Accept JWT via query param (EventSource can't set headers).""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("type") != "access": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + user = await db.get(User, payload["sub"]) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return user + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +@router.get("/events") +async def sse_stream(user: User = Depends(_get_user_from_token)): + q = bus.subscribe(user.id) + + async def generator(): + try: + while True: + try: + event = await asyncio.wait_for(q.get(), timeout=25.0) + yield f"data: {json.dumps(event)}\n\n" + except asyncio.TimeoutError: + # Heartbeat — SSE comment, ignored by EventSource + yield ": heartbeat\n\n" + except asyncio.CancelledError: + pass + finally: + bus.unsubscribe(user.id, q) + + return StreamingResponse( + generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # disable nginx/Apache buffering + "Connection": "keep-alive", + }, + ) diff --git a/src/routers/ingest.py b/src/routers/ingest.py new file mode 100644 index 0000000..77711ad --- /dev/null +++ b/src/routers/ingest.py @@ -0,0 +1,98 @@ +"""POST /api/ingest — receives session data from cc-collector.py hook.""" +from fastapi import APIRouter, Header, HTTPException, status +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import verify_api_key +from src.database import AsyncSessionLocal +from src.models import Project, Session +from src.schemas import IngestPayload, IngestResponse +from src.services.aggregator import recompute_daily_stat +from src.services.event_bus import bus + +router = APIRouter(prefix="/api/ingest", tags=["ingest"]) + + +def _slug_to_name(slug: str) -> str: + return slug.replace("-", " ").replace("_", " ").title() + + +@router.post("", response_model=IngestResponse) +async def ingest( + body: IngestPayload, + x_api_key: str = Header(..., alias="X-API-Key"), +): + async with AsyncSessionLocal() as db: + user = await verify_api_key(x_api_key, db) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + accepted = skipped = 0 + + for s in body.sessions: + # Get or create project + proj_result = await db.execute( + select(Project).where(Project.user_id == user.id, Project.slug == s.project_slug) + ) + project = proj_result.scalar_one_or_none() + if not project: + project = Project( + user_id=user.id, + slug=s.project_slug, + display_name=_slug_to_name(s.project_slug), + root_path=body.root_path, + ) + db.add(project) + await db.flush() + + # Upsert session (dedup by user_id + session_id + date) + stmt = insert(Session).values( + user_id=user.id, + project_id=project.id, + session_id=s.session_id, + date=s.date, + start_at=s.start_at, + end_at=s.end_at, + active_hours=s.active_hours, + message_count=s.message_count, + work_summary=s.work_summary, + commits=s.commits, + tools_used=s.tools_used, + files_changed=s.files_changed, + raw_stats=s.raw_stats, + ).on_conflict_do_update( + constraint="uq_session_user_date", + set_=dict( + end_at=s.end_at, + active_hours=s.active_hours, + message_count=s.message_count, + work_summary=s.work_summary, + commits=s.commits, + tools_used=s.tools_used, + files_changed=s.files_changed, + raw_stats=s.raw_stats, + ), + ) + result = await db.execute(stmt) + if result.rowcount: + accepted += 1 + else: + skipped += 1 + + await db.commit() + + # Recompute daily stats and push SSE for each affected (project, date) + affected = {(s.project_slug, s.date) for s in body.sessions} + for slug, stat_date in affected: + proj_result = await db.execute( + select(Project).where(Project.user_id == user.id, Project.slug == slug) + ) + project = proj_result.scalar_one_or_none() + if project: + await recompute_daily_stat(db, user.id, project.id, stat_date, user.daily_overhead_hours) + + # Notify SSE clients + await bus.publish(user.id, {"type": "session_update", "accepted": accepted}) + + return IngestResponse(accepted=accepted, skipped=skipped) diff --git a/src/routers/keys.py b/src/routers/keys.py new file mode 100644 index 0000000..4bc86fa --- /dev/null +++ b/src/routers/keys.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import CurrentUser, generate_api_key +from src.database import get_db +from src.models import ApiKey +from src.schemas import ApiKeyCreate, ApiKeyCreated, ApiKeyOut + +router = APIRouter(prefix="/api/keys", tags=["keys"]) + + +@router.get("", response_model=list[ApiKeyOut]) +async def list_keys(user: CurrentUser, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(ApiKey).where(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()) + ) + return result.scalars().all() + + +@router.post("", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED) +async def create_key(body: ApiKeyCreate, user: CurrentUser, db: AsyncSession = Depends(get_db)): + raw_key, prefix, key_hash = generate_api_key() + key = ApiKey(user_id=user.id, key_hash=key_hash, key_prefix=prefix, label=body.label) + db.add(key) + await db.commit() + await db.refresh(key) + return ApiKeyCreated( + id=key.id, label=key.label, key_prefix=key.key_prefix, + is_active=key.is_active, last_used_at=key.last_used_at, + created_at=key.created_at, raw_key=raw_key, + ) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_key(key_id: str, user: CurrentUser, db: AsyncSession = Depends(get_db)): + key = await db.get(ApiKey, key_id) + if not key or key.user_id != user.id: + raise HTTPException(status_code=404, detail="Key not found") + key.is_active = False + await db.commit() diff --git a/src/routers/projects.py b/src/routers/projects.py new file mode 100644 index 0000000..305b62e --- /dev/null +++ b/src/routers/projects.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import CurrentUser +from src.database import get_db +from src.models import Project +from src.schemas import ProjectOut + +router = APIRouter(prefix="/api/projects", tags=["projects"]) + + +@router.get("", response_model=list[ProjectOut]) +async def list_projects(user: CurrentUser, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Project).where(Project.user_id == user.id).order_by(Project.display_name) + ) + return result.scalars().all() + + +@router.patch("/{project_id}", response_model=ProjectOut) +async def update_project( + project_id: str, + body: dict, + user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + project = await db.get(Project, project_id) + if not project or project.user_id != user.id: + raise HTTPException(status_code=404, detail="Project not found") + if "display_name" in body: + project.display_name = str(body["display_name"])[:255] + await db.commit() + await db.refresh(project) + return project diff --git a/src/schemas.py b/src/schemas.py new file mode 100644 index 0000000..edbb1f1 --- /dev/null +++ b/src/schemas.py @@ -0,0 +1,194 @@ +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, EmailStr, Field + + +# ── Auth ────────────────────────────────────────────────────────────────────── + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(min_length=8) + + +# ── Users ───────────────────────────────────────────────────────────────────── + +class UserOut(BaseModel): + id: str + email: str + username: str + role: str + is_active: bool + daily_overhead_hours: float + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserCreate(BaseModel): + email: EmailStr + username: str = Field(min_length=2, max_length=100) + password: str = Field(min_length=8) + role: str = Field(default="user", pattern="^(admin|user)$") + + +class UserUpdate(BaseModel): + username: str | None = None + role: str | None = Field(default=None, pattern="^(admin|user)$") + is_active: bool | None = None + daily_overhead_hours: float | None = Field(default=None, ge=0, le=12) + + +# ── API Keys ────────────────────────────────────────────────────────────────── + +class ApiKeyOut(BaseModel): + id: str + label: str + key_prefix: str + is_active: bool + last_used_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class ApiKeyCreate(BaseModel): + label: str = Field(default="My Machine", max_length=100) + + +class ApiKeyCreated(ApiKeyOut): + raw_key: str # shown once + + +# ── Ingestion ───────────────────────────────────────────────────────────────── + +class SessionPayload(BaseModel): + session_id: str + project_slug: str + date: date + start_at: datetime + end_at: datetime + message_count: int = 0 + active_hours: float = 0.0 + work_summary: str = "" + commits: list[str] = [] + tools_used: dict[str, int] = {} + files_changed: list[str] = [] + raw_stats: dict[str, Any] = {} + + +class IngestPayload(BaseModel): + root_path: str = "" + sessions: list[SessionPayload] + + +class IngestResponse(BaseModel): + accepted: int + skipped: int + + +# ── Dashboard ───────────────────────────────────────────────────────────────── + +class KpiSummary(BaseModel): + total_hours: float + total_projects: int + working_days: int + total_sessions: int + avg_hours_per_day: float + top_project: str + total_commits: int + total_files_changed: int + period_from: date | None + period_to: date | None + + +class ProjectHours(BaseModel): + project_id: str + display_name: str + total_hours: float + session_count: int + working_days: int + last_active: date | None + + +class DailyPoint(BaseModel): + date: date + hours: float + sessions: int + + +class MonthlyPoint(BaseModel): + month: str # "2026-03" + hours: float + + +class DowPoint(BaseModel): + dow: int # 0=Mon … 6=Sun + label: str + hours: float + + +class ToolUsage(BaseModel): + tool: str + count: int + + +class SessionOut(BaseModel): + id: str + session_id: str + project_id: str + project_name: str + date: date + start_at: datetime + end_at: datetime + active_hours: float + message_count: int + work_summary: str + commits: list[str] + tools_used: dict[str, int] + files_changed: list[str] + + model_config = {"from_attributes": True} + + +class ProjectDetail(BaseModel): + project: "ProjectOut" + daily: list[DailyPoint] + sessions: list[SessionOut] + top_files: list[dict] + top_tools: list[ToolUsage] + + +class ProjectOut(BaseModel): + id: str + slug: str + display_name: str + root_path: str + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Admin ───────────────────────────────────────────────────────────────────── + +class AdminStats(BaseModel): + total_users: int + active_users: int + total_sessions: int + total_hours: float + users: list[UserOut] diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/aggregator.py b/src/services/aggregator.py new file mode 100644 index 0000000..f7caeab --- /dev/null +++ b/src/services/aggregator.py @@ -0,0 +1,67 @@ +"""Upsert daily_stats after session ingest.""" +from collections import defaultdict +from datetime import date + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models import DailyStat, Session + + +async def recompute_daily_stat( + db: AsyncSession, user_id: str, project_id: str, stat_date: date, + overhead_hours: float = 2.0, +): + """Recalculate daily_stats for (user, project, date) from raw sessions.""" + result = await db.execute( + select(Session).where( + Session.user_id == user_id, + Session.project_id == project_id, + Session.date == stat_date, + ) + ) + sessions = result.scalars().all() + + if not sessions: + return + + total_raw_hours = sum(s.active_hours for s in sessions) + session_count = len(sessions) + message_count = sum(s.message_count for s in sessions) + commit_count = sum(len(s.commits) for s in sessions) + + files: set = set() + for s in sessions: + files.update(s.files_changed or []) + + tools: dict[str, int] = defaultdict(int) + for s in sessions: + for tool, cnt in (s.tools_used or {}).items(): + tools[tool] += cnt + + # Overhead is distributed proportionally at query time (not stored per-project) + # Store raw hours here; overhead added in dashboard queries + stmt = insert(DailyStat).values( + user_id=user_id, + project_id=project_id, + date=stat_date, + total_hours=round(total_raw_hours, 4), + session_count=session_count, + message_count=message_count, + commit_count=commit_count, + files_changed_count=len(files), + top_tools=dict(tools), + ).on_conflict_do_update( + constraint="uq_daily_stat", + set_=dict( + total_hours=round(total_raw_hours, 4), + session_count=session_count, + message_count=message_count, + commit_count=commit_count, + files_changed_count=len(files), + top_tools=dict(tools), + ), + ) + await db.execute(stmt) + await db.commit() diff --git a/src/services/event_bus.py b/src/services/event_bus.py new file mode 100644 index 0000000..283dd78 --- /dev/null +++ b/src/services/event_bus.py @@ -0,0 +1,30 @@ +"""In-memory pub/sub for SSE. One asyncio.Queue per connected client.""" +import asyncio +from collections import defaultdict + + +class EventBus: + def __init__(self): + self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list) + + def subscribe(self, user_id: str) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=50) + self._queues[user_id].append(q) + return q + + def unsubscribe(self, user_id: str, q: asyncio.Queue): + try: + self._queues[user_id].remove(q) + except ValueError: + pass + + async def publish(self, user_id: str, event: dict): + """Push event to all queues for a user. Drop if queue full (non-blocking).""" + for q in list(self._queues.get(user_id, [])): + try: + q.put_nowait(event) + except asyncio.QueueFull: + pass + + +bus = EventBus() diff --git a/src/static/collector/cc-collector.py b/src/static/collector/cc-collector.py new file mode 100644 index 0000000..4076782 --- /dev/null +++ b/src/static/collector/cc-collector.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +CC Dashboard Collector +====================== +Reads recent Claude Code session logs from ~/.claude/projects/ and POSTs them +to the CC Dashboard API. Designed to run as a Claude Code Stop hook. + +Setup: + 1. Save this file to ~/.claude/cc-collector.py + 2. In your Claude Code settings.json, add: + "hooks": { + "Stop": [{"hooks": [{"type": "command", + "command": "CC_API_KEY=cc_YOUR_KEY CC_SERVER=https://your-server/cc-dashboard python3 ~/.claude/cc-collector.py 2>/dev/null || true", + "async": true, "statusMessage": "Syncing to CC Dashboard…"}]}] + } + +Environment variables: + CC_API_KEY — your API key from CC Dashboard + CC_SERVER — full base URL, e.g. https://optical-dev.oliver.solutions/cc-dashboard + CC_ROOT_PATH — (optional) label for your projects root dir + CC_LOOKBACK — how many hours back to scan (default: 2) +""" +import json +import os +import re +import subprocess +from collections import defaultdict +from datetime import datetime, timezone, timedelta +from pathlib import Path + + +# ── Config ──────────────────────────────────────────────────────────────────── +API_KEY = os.environ.get("CC_API_KEY", "") +SERVER = os.environ.get("CC_SERVER", "https://optical-dev.oliver.solutions/cc-dashboard").rstrip("/") +ROOT_PATH = os.environ.get("CC_ROOT_PATH", str(Path.home())) +LOOKBACK_HOURS = int(os.environ.get("CC_LOOKBACK", "2")) + +CLAUDE_PROJECTS = Path.home() / ".claude" / "projects" +STATE_FILE = Path.home() / ".claude" / ".cc-collector-state.json" + +# Tools that indicate meaningful work (skip internal/meta tools) +SKIP_TOOLS = {"ExitPlanMode", "EnterPlanMode", "TodoWrite", "TodoRead", + "TaskCreate", "TaskUpdate", "TaskGet", "TaskList"} + + +# ── State ───────────────────────────────────────────────────────────────────── +def _load_state() -> dict: + if STATE_FILE.exists(): + try: + return json.loads(STATE_FILE.read_text()) + except Exception: + pass + return {} + + +def _save_state(state: dict): + STATE_FILE.write_text(json.dumps(state, default=str)) + + +# ── Session parsing ─────────────────────────────────────────────────────────── +def _active_hours(timestamps: list, gap_minutes: int = 60) -> float: + if not timestamps: + return 0.25 + if len(timestamps) < 2: + return 0.5 + total = 0.0 + gap_sec = gap_minutes * 60 + for i in range(1, len(timestamps)): + diff = (timestamps[i] - timestamps[i - 1]).total_seconds() + if diff <= gap_sec: + total += diff + total += 15 * 60 # 15min overhead per session + return max(total / 3600, 0.25) + + +def _extract_work(messages: list) -> dict: + agent_tasks, files_changed, bash_ops = [], set(), [] + seen_agents = set() + + for obj in messages: + msg = obj.get("message", {}) + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name", "") + inp = block.get("input", {}) + if name in SKIP_TOOLS: + continue + if name == "Agent": + desc = inp.get("description", "").strip() + if desc and desc not in seen_agents: + agent_tasks.append(desc) + seen_agents.add(desc) + elif name in ("Write", "Edit", "NotebookEdit"): + fp = inp.get("file_path", "") + if fp and not fp.endswith(".md"): + files_changed.add(fp.split("/")[-1]) + elif name == "Bash": + cmd = inp.get("command", "").strip() + if any(cmd.startswith(p) for p in + ("git commit", "git push", "npm run", "npm install", + "docker", "python", "pytest", "uv ")): + short = cmd.split("\n")[0][:80] + if short not in bash_ops: + bash_ops.append(short) + + return { + "agent_tasks": agent_tasks[:6], + "files_changed": sorted(files_changed)[:10], + "bash_ops": bash_ops[:4], + } + + +def _tool_counts(messages: list) -> dict: + counts = defaultdict(int) + for obj in messages: + msg = obj.get("message", {}) + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + for block in (msg.get("content") or []): + if isinstance(block, dict) and block.get("type") == "tool_use": + name = block.get("name", "") + if name and name not in SKIP_TOOLS: + counts[name] += 1 + return dict(counts) + + +def _git_commits(repo_path: Path, start: datetime, end: datetime) -> list: + try: + since = start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + until = end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + result = subprocess.run( + ["git", "-C", str(repo_path), "log", + f"--after={since}", f"--before={until}", + "--pretty=format:%s", "--no-merges"], + capture_output=True, text=True, timeout=5, + ) + return [l.strip() for l in result.stdout.splitlines() if l.strip()] + except Exception: + return [] + + +def _build_summary(commits: list, work: dict) -> str: + if commits: + return "\n".join(f"• {c}" for c in commits[:8]) + parts = [f"• {t}" for t in work["agent_tasks"]] + if work["files_changed"]: + parts.append("Files: " + ", ".join(work["files_changed"])) + return "\n".join(parts) if parts else "—" + + +def _slug_to_name(folder_name: str) -> str: + # Strip common path prefixes like "-Volumes-SSD-Projects-Oliver-" + # Keep only the last segment + parts = folder_name.split("-") + # Find where the actual project slug starts (heuristic: last non-empty segment after common prefix) + return folder_name.replace("-", " ").replace("_", " ").title().strip() + + +# ── Main scan ───────────────────────────────────────────────────────────────── +def collect() -> list: + if not CLAUDE_PROJECTS.exists(): + return [] + + state = _load_state() + cutoff = datetime.now(timezone.utc) - timedelta(hours=LOOKBACK_HOURS) + sessions_to_send = [] + + for folder in sorted(CLAUDE_PROJECTS.iterdir()): + if not folder.is_dir(): + continue + folder_key = folder.name + + # Infer project slug: strip machine path prefix, use last component + slug = _infer_slug(folder_key) + + # Raw sessions grouped by session_id + raw_sessions: dict = defaultdict(lambda: {"timestamps": [], "messages": []}) + + for jf in sorted(folder.glob("*.jsonl")): + # Skip if file hasn't been modified since last run + cutoff + mtime = datetime.fromtimestamp(jf.stat().st_mtime, tz=timezone.utc) + last_sync = state.get(str(jf)) + if last_sync and mtime <= datetime.fromisoformat(last_sync): + continue + + try: + with open(jf, encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + ts = obj.get("timestamp") + sid = obj.get("sessionId") + if not ts or not sid: + continue + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + continue + if dt < cutoff: + continue + raw_sessions[sid]["timestamps"].append(dt) + raw_sessions[sid]["messages"].append(obj) + except Exception: + continue + + # Update state + state[str(jf)] = datetime.now(timezone.utc).isoformat() + + # Try to find repo root + # Derive repo path from folder name by replacing dashes with slashes after common prefix + repo_path = _infer_repo_path(folder_key) + + for sid, data in raw_sessions.items(): + if not data["timestamps"]: + continue + + paired = sorted(zip(data["timestamps"], data["messages"]), key=lambda x: x[0]) + + # Split by calendar day + day_buckets: dict = defaultdict(lambda: {"timestamps": [], "messages": []}) + for dt, obj in paired: + day_buckets[dt.strftime("%Y-%m-%d")]["timestamps"].append(dt) + day_buckets[dt.strftime("%Y-%m-%d")]["messages"].append(obj) + + for date_str, bucket in day_buckets.items(): + ts_sorted = bucket["timestamps"] + start = ts_sorted[0] + end = ts_sorted[-1] + hours = _active_hours(ts_sorted) + work = _extract_work(bucket["messages"]) + tools = _tool_counts(bucket["messages"]) + commits = _git_commits(repo_path, start, end) if (repo_path and repo_path.exists()) else [] + summary = _build_summary(commits, work) + + sessions_to_send.append({ + "session_id": sid, + "project_slug": slug, + "date": date_str, + "start_at": start.isoformat(), + "end_at": end.isoformat(), + "message_count": len(ts_sorted), + "active_hours": round(hours, 4), + "work_summary": summary, + "commits": commits, + "tools_used": tools, + "files_changed": work["files_changed"], + "raw_stats": {}, + }) + + _save_state(state) + return sessions_to_send + + +def _infer_slug(folder_name: str) -> str: + """Convert folder name like '-Volumes-SSD-Projects-Oliver-semblance' → 'semblance'.""" + # Remove leading dashes + name = folder_name.lstrip("-") + # Split on dashes, take last meaningful segment + parts = name.split("-") + if len(parts) >= 1: + return parts[-1] or name + return name + + +def _infer_repo_path(folder_name: str) -> Path | None: + """Convert folder name to filesystem path by replacing '-' with '/' (heuristic).""" + try: + path_str = "/" + folder_name.lstrip("-").replace("-", "/") + # Walk up from the inferred path until we find a directory that exists + p = Path(path_str) + while p != p.parent: + if p.exists() and p.is_dir(): + return p + p = p.parent + except Exception: + pass + return None + + +# ── Send ────────────────────────────────────────────────────────────────────── +def send(sessions: list): + if not sessions: + return + payload = json.dumps({ + "root_path": ROOT_PATH, + "sessions": sessions, + }) + + import urllib.request + req = urllib.request.Request( + f"{SERVER}/api/ingest", + data=payload.encode(), + headers={ + "Content-Type": "application/json", + "X-API-Key": API_KEY, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read()) + except Exception as e: + pass # silent fail — hook must not block Claude + + +if __name__ == "__main__": + if not API_KEY: + raise SystemExit("CC_API_KEY not set") + sessions = collect() + if sessions: + send(sessions) diff --git a/src/static/css/app.css b/src/static/css/app.css new file mode 100644 index 0000000..d5f4288 --- /dev/null +++ b/src/static/css/app.css @@ -0,0 +1,448 @@ +@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap'); + +:root { + --accent: #FFC407; + --accent-dark: #e6af00; + --bg: #0f0f0f; + --surface: #1a1a1a; + --surface2: #242424; + --border: #2e2e2e; + --text: #f0f0f0; + --text-muted: #888; + --danger: #ef4444; + --success: #22c55e; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Montserrat', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +/* ── Layout ────────────────────────────────────────── */ +#app { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: 220px; + min-width: 220px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-logo { + padding: 24px 20px 20px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--border); +} + +.sidebar-logo span { + font-size: 18px; + font-weight: 800; + letter-spacing: -0.5px; +} + +.sidebar-logo .accent { color: var(--accent); } + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + transition: all .15s; + text-decoration: none; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: var(--accent); color: #000; font-weight: 700; } +.nav-item .icon { font-size: 16px; width: 20px; text-align: center; } + +.sidebar-bottom { + padding: 12px 8px; + border-top: 1px solid var(--border); +} + +.sidebar-user { + padding: 10px 12px; + font-size: 12px; + color: var(--text-muted); +} + +.sidebar-user strong { display: block; color: var(--text); font-size: 13px; margin-bottom: 2px; } + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.topbar { + height: 56px; + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 24px; + gap: 16px; + flex-shrink: 0; +} + +.topbar h1 { font-size: 16px; font-weight: 700; flex: 1; } + +.content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* ── Date range picker ─────────────────────────────── */ +.date-range { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 12px; + font-size: 12px; +} + +.date-range input[type="date"] { + background: none; + border: none; + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; + outline: none; +} + +.date-range input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.7); +} + +/* ── KPI Cards ─────────────────────────────────────── */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.kpi-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: border-color .15s; +} + +.kpi-card:hover { border-color: var(--accent); } +.kpi-card .label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 8px; } +.kpi-card .value { font-size: 28px; font-weight: 800; color: var(--text); line-height: 1; } +.kpi-card .value.accent { color: var(--accent); } +.kpi-card .sub { font-size: 11px; color: var(--text-muted); margin-top: 6px; } + +/* ── Chart grid ────────────────────────────────────── */ +.chart-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.chart-card.wide { grid-column: 1 / -1; } + +.chart-card h3 { + font-size: 13px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .05em; + margin-bottom: 16px; +} + +.chart-wrap { position: relative; height: 260px; } +.chart-wrap.tall { height: 320px; } + +/* ── Tables ────────────────────────────────────────── */ +.table-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + margin-bottom: 24px; +} + +.table-card h3 { + font-size: 13px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .05em; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +table { width: 100%; border-collapse: collapse; font-size: 13px; } + +th { + padding: 10px 16px; + text-align: left; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .05em; + background: var(--surface2); + border-bottom: 1px solid var(--border); +} + +td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + color: var(--text); +} + +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--surface2); } + +td .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.badge-accent { background: var(--accent); color: #000; } +.badge-muted { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); } +.badge-success { background: rgba(34,197,94,.15); color: var(--success); } +.badge-danger { background: rgba(239,68,68,.15); color: var(--danger); } + +/* ── Buttons ───────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all .15s; + border: none; + text-decoration: none; +} + +.btn-primary { background: var(--accent); color: #000; } +.btn-primary:hover { background: var(--accent-dark); } +.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } +.btn-danger { background: rgba(239,68,68,.15); color: var(--danger); border: 1px solid rgba(239,68,68,.3); } +.btn-danger:hover { background: var(--danger); color: #fff; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ── Forms ─────────────────────────────────────────── */ +.form-group { margin-bottom: 16px; } +.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; } + +input[type="text"], +input[type="email"], +input[type="password"], +select { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + color: var(--text); + font-family: inherit; + font-size: 13px; + outline: none; + transition: border-color .15s; +} + +input:focus, select:focus { border-color: var(--accent); } + +/* ── Login ─────────────────────────────────────────── */ +#login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--bg); +} + +.login-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px; + width: 360px; +} + +.login-box .logo { + font-size: 22px; + font-weight: 800; + text-align: center; + margin-bottom: 32px; +} + +.login-box .logo .accent { color: var(--accent); } + +.error-msg { + background: rgba(239,68,68,.12); + border: 1px solid rgba(239,68,68,.3); + color: var(--danger); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + margin-bottom: 16px; + display: none; +} + +/* ── Live feed ─────────────────────────────────────── */ +.feed-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); + animation: fadeIn .3s ease; +} + +@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } } + +.feed-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + margin-top: 5px; + flex-shrink: 0; +} + +.feed-content { flex: 1; } +.feed-content .project { font-size: 13px; font-weight: 600; } +.feed-content .summary { font-size: 12px; color: var(--text-muted); margin-top: 2px; white-space: pre-wrap; } +.feed-time { font-size: 11px; color: var(--text-muted); flex-shrink: 0; } + +/* ── Modals ────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.7); + display: flex; align-items: center; justify-content: center; + z-index: 1000; + display: none; +} + +.modal-overlay.open { display: flex; } + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + width: 460px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; +} + +.modal h2 { font-size: 16px; font-weight: 700; margin-bottom: 24px; } + +/* ── SSE indicator ─────────────────────────────────── */ +.sse-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--text-muted); + display: inline-block; + transition: background .3s; +} + +.sse-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); } +.sse-dot.error { background: var(--danger); } + +/* ── Code block (for hook setup) ──────────────────── */ +.code-block { + background: #0a0a0a; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + font-family: 'Courier New', monospace; + font-size: 12px; + color: #aef; + white-space: pre-wrap; + word-break: break-all; + position: relative; +} + +.copy-btn { + position: absolute; + top: 8px; right: 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 10px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + font-family: inherit; +} + +.copy-btn:hover { color: var(--accent); border-color: var(--accent); } + +/* ── Scrollbar ─────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ── Misc ──────────────────────────────────────────── */ +.loading { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; } +.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); } +.empty .big { font-size: 40px; margin-bottom: 12px; } +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.section-header h2 { font-size: 15px; font-weight: 700; } +.tag { display: inline-block; background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 7px; font-size: 11px; color: var(--text-muted); margin: 2px; } + +/* ── Progress bar ──────────────────────────────────── */ +.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .3s; } + +/* ── Page transitions ──────────────────────────────── */ +.page { animation: pageIn .2s ease; } +@keyframes pageIn { from { opacity: 0; } to { opacity: 1; } } diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..67c5018 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,31 @@ + + + + + + CC Dashboard + + + + + + + + + +
+ + + + + + + + + + + + + + + diff --git a/src/static/js/api.js b/src/static/js/api.js new file mode 100644 index 0000000..73aaa53 --- /dev/null +++ b/src/static/js/api.js @@ -0,0 +1,131 @@ +/** + * Fetch wrapper with JWT auth + auto-refresh. + */ +const BASE = window.CC_BASE || ''; + +const Api = (() => { + let _accessToken = localStorage.getItem('cc_access') || ''; + let _refreshToken = localStorage.getItem('cc_refresh') || ''; + let _refreshing = null; + + function setTokens(access, refresh) { + _accessToken = access; + _refreshToken = refresh; + localStorage.setItem('cc_access', access); + localStorage.setItem('cc_refresh', refresh); + } + + function clearTokens() { + _accessToken = _refreshToken = ''; + localStorage.removeItem('cc_access'); + localStorage.removeItem('cc_refresh'); + } + + async function _doRefresh() { + const res = await fetch(`${BASE}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: _refreshToken }), + }); + if (!res.ok) throw new Error('Refresh failed'); + const data = await res.json(); + setTokens(data.access_token, data.refresh_token); + } + + async function request(path, opts = {}) { + if (!opts.headers) opts.headers = {}; + if (_accessToken) opts.headers['Authorization'] = `Bearer ${_accessToken}`; + + let res = await fetch(`${BASE}${path}`, opts); + + if (res.status === 401 && _refreshToken) { + if (!_refreshing) _refreshing = _doRefresh().finally(() => (_refreshing = null)); + try { + await _refreshing; + opts.headers['Authorization'] = `Bearer ${_accessToken}`; + res = await fetch(`${BASE}${path}`, opts); + } catch { + clearTokens(); + window.location.reload(); + return; + } + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'Request failed'); + } + + if (res.status === 204) return null; + return res.json(); + } + + function get(path, params) { + let url = path; + if (params) { + const q = new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== null && v !== undefined) + ).toString(); + if (q) url += '?' + q; + } + return request(url); + } + + function post(path, body) { + return request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function put(path, body) { + return request(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function patch(path, body) { + return request(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function del(path) { + return request(path, { method: 'DELETE' }); + } + + async function login(email, password) { + const res = await fetch(`${BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: 'Login failed' })); + throw new Error(err.detail); + } + const data = await res.json(); + setTokens(data.access_token, data.refresh_token); + return data; + } + + function logout() { + clearTokens(); + window.location.reload(); + } + + function isLoggedIn() { + return !!_accessToken; + } + + function getAccessToken() { + return _accessToken; + } + + return { get, post, put, patch, del, login, logout, isLoggedIn, getAccessToken, setTokens }; +})(); diff --git a/src/static/js/app.js b/src/static/js/app.js new file mode 100644 index 0000000..b2c7fc6 --- /dev/null +++ b/src/static/js/app.js @@ -0,0 +1,159 @@ +/** + * SPA Router & app init. + */ +window.CC_BASE = '/cc-dashboard'; + +const App = (() => { + let _currentUser = null; + + const NAV = [ + { id: 'dashboard', label: 'Dashboard', icon: '◈', page: DashboardPage }, + { id: 'projects', label: 'Projects', icon: '▤', page: ProjectsPage }, + { id: 'live', label: 'Live Feed', icon: '⚡', page: LivePage }, + { id: 'keys', label: 'API Keys', icon: '⌘', page: KeysPage }, + { id: 'settings', label: 'Settings', icon: '⚙', page: SettingsPage }, + ]; + + async function init() { + if (!Api.isLoggedIn()) { + renderLogin(); + return; + } + + try { + _currentUser = await Api.get('/api/auth/me'); + } catch { + renderLogin(); + return; + } + + renderShell(); + SSE.connect(); + + // Route from hash + const hash = location.hash.replace('#', '') || 'dashboard'; + const [page, param] = hash.split('/'); + navigate(page, param); + } + + function renderLogin() { + document.getElementById('app').innerHTML = ` +
+ +
+ `; + + const doLogin = async () => { + const email = document.getElementById('login-email').value.trim(); + const password = document.getElementById('login-password').value; + const errEl = document.getElementById('login-error'); + errEl.style.display = 'none'; + try { + await Api.login(email, password); + init(); + } catch (e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } + }; + + document.getElementById('btn-login').onclick = doLogin; + document.getElementById('login-password').addEventListener('keydown', e => { + if (e.key === 'Enter') doLogin(); + }); + } + + function renderShell() { + const isAdmin = _currentUser?.role === 'admin'; + const navItems = [...NAV, ...(isAdmin ? [{ id: 'admin', label: 'Admin', icon: '⚀', page: AdminPage }] : [])]; + + document.getElementById('app').innerHTML = ` + +
+
+

Dashboard

+ +
+
+
+ `; + + SSE.setDot(document.getElementById('sse-dot')); + } + + function navigate(pageId, param) { + const isAdmin = _currentUser?.role === 'admin'; + const allPages = { dashboard: DashboardPage, projects: ProjectsPage, live: LivePage, keys: KeysPage, settings: SettingsPage }; + if (isAdmin) allPages.admin = AdminPage; + // Special: project detail + if (pageId === 'project' && param) { + location.hash = `project/${param}`; + const content = document.getElementById('page-content'); + if (!content) return; + document.getElementById('page-title').textContent = 'Project Detail'; + _setActiveNav(null); + ProjectDetailPage.render(content, param); + return; + } + + const page = allPages[pageId] || DashboardPage; + const navDef = [...NAV, { id: 'admin' }].find(n => n.id === pageId) || NAV[0]; + + location.hash = pageId; + const content = document.getElementById('page-content'); + if (!content) return; + + document.getElementById('page-title').textContent = + NAV.find(n => n.id === pageId)?.label || (pageId === 'admin' ? 'Admin' : 'Dashboard'); + + _setActiveNav(pageId); + page.render(content, param); + } + + function _setActiveNav(pageId) { + document.querySelectorAll('.nav-item').forEach(el => { + el.classList.toggle('active', el.dataset.page === pageId); + }); + } + + return { init, navigate }; +})(); + +document.addEventListener('DOMContentLoaded', () => App.init()); +window.addEventListener('hashchange', () => { + const hash = location.hash.replace('#', '') || 'dashboard'; + const [page, param] = hash.split('/'); + App.navigate(page, param); +}); diff --git a/src/static/js/charts.js b/src/static/js/charts.js new file mode 100644 index 0000000..c3fb24f --- /dev/null +++ b/src/static/js/charts.js @@ -0,0 +1,234 @@ +/** + * Chart.js configuration & factory. + */ +const ACCENT = '#FFC407'; +const GRID_COLOR = 'rgba(255,255,255,0.06)'; +const TEXT_COLOR = '#888'; +const FONT = 'Montserrat'; + +Chart.defaults.color = TEXT_COLOR; +Chart.defaults.font.family = FONT; +Chart.defaults.font.size = 11; + +const ChartDefs = { + base() { + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 400 }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: '#1a1a1a', + borderColor: '#2e2e2e', + borderWidth: 1, + titleColor: '#f0f0f0', + bodyColor: '#888', + padding: 10, + titleFont: { family: FONT, weight: '700', size: 12 }, + bodyFont: { family: FONT, size: 11 }, + }, + }, + }; + }, + + hoursBar(labels, data) { + return { + type: 'bar', + data: { + labels, + datasets: [{ + data, + backgroundColor: ACCENT, + borderRadius: 6, + barThickness: 18, + }], + }, + options: { + ...this.base(), + indexAxis: 'y', + scales: { + x: { + grid: { color: GRID_COLOR }, + ticks: { callback: v => v + 'h' }, + }, + y: { grid: { display: false } }, + }, + plugins: { + ...this.base().plugins, + tooltip: { + ...this.base().plugins.tooltip, + callbacks: { label: ctx => ` ${ctx.parsed.x.toFixed(1)}h` }, + }, + }, + }, + }; + }, + + donut(labels, data) { + const colors = [ACCENT, '#fff176', '#e6af00', '#ffd740', '#fff9c4', '#ffb300', '#ffe082', '#ffca28']; + return { + type: 'doughnut', + data: { + labels, + datasets: [{ + data, + backgroundColor: colors.slice(0, data.length), + borderWidth: 0, + hoverOffset: 8, + }], + }, + options: { + ...this.base(), + cutout: '68%', + plugins: { + ...this.base().plugins, + legend: { + display: true, + position: 'right', + labels: { + color: TEXT_COLOR, + font: { family: FONT, size: 11 }, + boxWidth: 10, + padding: 12, + }, + }, + tooltip: { + ...this.base().plugins.tooltip, + callbacks: { + label: ctx => { + const total = ctx.dataset.data.reduce((a, b) => a + b, 0); + const pct = total ? ((ctx.parsed / total) * 100).toFixed(1) : 0; + return ` ${ctx.parsed.toFixed(1)}h (${pct}%)`; + }, + }, + }, + }, + }, + }; + }, + + lineArea(labels, data, label = 'Hours') { + return { + type: 'line', + data: { + labels, + datasets: [{ + label, + data, + borderColor: ACCENT, + backgroundColor: 'rgba(255,196,7,0.12)', + fill: true, + tension: 0.35, + pointRadius: 3, + pointBackgroundColor: ACCENT, + borderWidth: 2, + }], + }, + options: { + ...this.base(), + scales: { + x: { grid: { color: GRID_COLOR } }, + y: { + grid: { color: GRID_COLOR }, + ticks: { callback: v => v + 'h' }, + }, + }, + plugins: { + ...this.base().plugins, + tooltip: { + ...this.base().plugins.tooltip, + callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` }, + }, + }, + }, + }; + }, + + columnBar(labels, data) { + return { + type: 'bar', + data: { + labels, + datasets: [{ + data, + backgroundColor: ACCENT, + borderRadius: 6, + }], + }, + options: { + ...this.base(), + scales: { + x: { grid: { display: false } }, + y: { + grid: { color: GRID_COLOR }, + ticks: { callback: v => v + 'h' }, + }, + }, + plugins: { + ...this.base().plugins, + tooltip: { + ...this.base().plugins.tooltip, + callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` }, + }, + }, + }, + }; + }, + + timeline(sessions) { + // Gantt-like bars: each session = one horizontal bar + const labels = sessions.map(s => s.project_name.substring(0, 20)); + const starts = sessions.map(s => new Date(s.start_at).getTime()); + const ends = sessions.map(s => new Date(s.end_at).getTime()); + const data = sessions.map((s, i) => [starts[i], ends[i]]); + + return { + type: 'bar', + data: { + labels, + datasets: [{ + data, + backgroundColor: ACCENT + 'cc', + borderSkipped: false, + borderRadius: 4, + barThickness: 16, + }], + }, + options: { + ...this.base(), + indexAxis: 'y', + scales: { + x: { + type: 'time', + time: { unit: 'hour', displayFormats: { hour: 'HH:mm' } }, + grid: { color: GRID_COLOR }, + }, + y: { grid: { display: false } }, + }, + plugins: { + ...this.base().plugins, + tooltip: { + ...this.base().plugins.tooltip, + callbacks: { + label: ctx => { + const s = sessions[ctx.dataIndex]; + const from = new Date(s.start_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }); + const to = new Date(s.end_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }); + return ` ${from} – ${to} (${s.active_hours.toFixed(1)}h)`; + }, + }, + }, + }, + }, + }; + }, +}; + +function makeChart(canvasId, config) { + const canvas = document.getElementById(canvasId); + if (!canvas) return null; + const existing = Chart.getChart(canvas); + if (existing) existing.destroy(); + return new Chart(canvas, config); +} diff --git a/src/static/js/pages/admin.js b/src/static/js/pages/admin.js new file mode 100644 index 0000000..077ddda --- /dev/null +++ b/src/static/js/pages/admin.js @@ -0,0 +1,110 @@ +const AdminPage = (() => { + async function render(container) { + container.innerHTML = `
Loading…
`; + try { + const stats = await Api.get('/api/admin/stats'); + container.innerHTML = ` +
+
+

Admin

+ +
+ +
+
Total Users
${stats.total_users}
+
Active Users
${stats.active_users}
+
Total Sessions
${stats.total_sessions}
+
Total Hours
${stats.total_hours.toFixed(1)}h
+
+ +
+

Users

+ + + + + + ${stats.users.map(u => _userRow(u)).join('')} + +
UserEmailRoleStatusJoined
+
+
+ + + + `; + + document.getElementById('btn-new-user').onclick = () => + document.getElementById('new-user-modal').classList.add('open'); + + document.getElementById('btn-cancel-user').onclick = () => + document.getElementById('new-user-modal').classList.remove('open'); + + document.getElementById('btn-save-user').onclick = async () => { + const errEl = document.getElementById('nu-error'); + errEl.style.display = 'none'; + try { + await Api.post('/api/admin/users', { + email: document.getElementById('nu-email').value, + username: document.getElementById('nu-username').value, + password: document.getElementById('nu-password').value, + role: document.getElementById('nu-role').value, + }); + document.getElementById('new-user-modal').classList.remove('open'); + render(container); + } catch (e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } + }; + + // Toggle active + container.querySelectorAll('[data-toggle]').forEach(btn => { + btn.onclick = async () => { + const uid = btn.dataset.toggle; + const active = btn.dataset.active === 'true'; + if (!confirm(`${active ? 'Deactivate' : 'Activate'} this user?`)) return; + try { + await Api.put(`/api/admin/users/${uid}`, { is_active: !active }); + render(container); + } catch (e) { alert(e.message); } + }; + }); + + } catch (e) { + container.innerHTML = `
⚠️
${e.message}
`; + } + } + + function _userRow(u) { + return ` + ${u.username} + ${u.email} + ${u.role === 'admin' ? 'Admin' : 'User'} + ${u.is_active ? 'Active' : 'Inactive'} + ${new Date(u.created_at).toLocaleDateString()} + + `; + } + + return { render }; +})(); diff --git a/src/static/js/pages/dashboard.js b/src/static/js/pages/dashboard.js new file mode 100644 index 0000000..2092daf --- /dev/null +++ b/src/static/js/pages/dashboard.js @@ -0,0 +1,172 @@ +const DashboardPage = (() => { + let _from = null, _to = null; + + function _fmtDate(d) { + return new Date(d).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }); + } + + async function render(container) { + const today = new Date().toISOString().split('T')[0]; + const d30ago = new Date(Date.now() - 29 * 86400000).toISOString().split('T')[0]; + if (!_from) _from = d30ago; + if (!_to) _to = today; + + container.innerHTML = ` +
+
+

Dashboard

+
+
+ From + + To + +
+ +
+
+ +
+ ${[...Array(8)].map(() => `
...
`).join('')} +
+ +
+
+

Hours by Project

+
+
+
+

Project Share

+
+
+
+

Daily Activity (last 30 days)

+
+
+
+

Monthly Trend

+
+
+
+

Day of Week

+
+
+
+

Tools Used

+
+
+
+

Today's Sessions

+
+
+
+
+ `; + + document.getElementById('dash-apply').addEventListener('click', () => { + _from = document.getElementById('dash-from').value; + _to = document.getElementById('dash-to').value; + loadData(container); + }); + + loadData(container); + + // Listen for SSE updates + SSE.on('session_update', () => loadData(container)); + } + + async function loadData(container) { + const params = { from: _from, to: _to }; + + try { + const [summary, projects, timeline, monthly, dow, tools, activity] = await Promise.all([ + Api.get('/api/dashboard/summary', params), + Api.get('/api/dashboard/projects', params), + Api.get('/api/dashboard/timeline', { days: 30 }), + Api.get('/api/dashboard/monthly'), + Api.get('/api/dashboard/dow'), + Api.get('/api/dashboard/tools', params), + Api.get('/api/dashboard/activity'), + ]); + + renderKpis(summary); + renderCharts(projects, timeline, monthly, dow, tools, activity); + } catch (e) { + console.error('Dashboard load error:', e); + } + } + + function renderKpis(s) { + const grid = document.getElementById('kpi-grid'); + if (!grid) return; + const cards = [ + { label: 'Total Hours', value: s.total_hours.toFixed(1) + 'h', accent: true }, + { label: 'Projects', value: s.total_projects }, + { label: 'Working Days', value: s.working_days }, + { label: 'Sessions', value: s.total_sessions }, + { label: 'Avg / Day', value: s.avg_hours_per_day.toFixed(1) + 'h' }, + { label: 'Top Project', value: s.top_project, sub: '' }, + { label: 'Commits', value: s.total_commits }, + { label: 'Files Changed', value: s.total_files_changed }, + ]; + grid.innerHTML = cards.map(c => ` +
+
${c.label}
+
${c.value}
+ ${c.sub !== undefined ? `
${c.sub}
` : ''} +
+ `).join(''); + } + + function renderCharts(projects, timeline, monthly, dow, tools, activity) { + // Hours by project (top 12) + const top12 = projects.slice(0, 12); + makeChart('chart-proj-bar', ChartDefs.hoursBar( + top12.map(p => p.display_name), + top12.map(p => p.total_hours), + )); + + // Donut + const top8 = projects.slice(0, 8); + makeChart('chart-donut', ChartDefs.donut( + top8.map(p => p.display_name), + top8.map(p => p.total_hours), + )); + + // Daily line + makeChart('chart-daily', ChartDefs.lineArea( + timeline.map(d => d.date), + timeline.map(d => d.hours), + )); + + // Monthly + makeChart('chart-monthly', ChartDefs.columnBar( + monthly.map(m => m.month.slice(0, 7)), + monthly.map(m => m.hours), + )); + + // DOW + makeChart('chart-dow', ChartDefs.columnBar( + dow.map(d => d.label), + dow.map(d => d.hours), + )); + + // Tools + const topTools = tools.slice(0, 10); + makeChart('chart-tools', ChartDefs.hoursBar( + topTools.map(t => t.tool), + topTools.map(t => t.count), + )); + + // Timeline (today's sessions) + if (activity.length > 0) { + document.getElementById('timeline-wrap').style.height = Math.max(160, activity.length * 36) + 'px'; + makeChart('chart-timeline', ChartDefs.timeline(activity)); + } else { + const wrap = document.getElementById('timeline-wrap'); + if (wrap) wrap.innerHTML = '
No sessions today
'; + } + } + + return { render }; +})(); diff --git a/src/static/js/pages/keys.js b/src/static/js/pages/keys.js new file mode 100644 index 0000000..46a9205 --- /dev/null +++ b/src/static/js/pages/keys.js @@ -0,0 +1,150 @@ +const KeysPage = (() => { + let _keys = []; + + async function render(container) { + container.innerHTML = `
Loading…
`; + await load(container); + } + + async function load(container) { + try { + _keys = await Api.get('/api/keys'); + container.innerHTML = ` +
+
+

API Keys

+ +
+ +
+ + + + ${_keys.length ? _keys.map(k => ` + + + + + + + + `).join('') : ''} + +
LabelPrefixLast UsedStatus
${k.label}${k.key_prefix}…${k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never'}${k.is_active ? 'Active' : 'Revoked'}${k.is_active ? `` : ''}
No API keys yet
+
+ +
+

Hook Setup Instructions

+

+ 1. Create an API key above.
+ 2. Download cc-collector.py and save to ~/.claude/cc-collector.py
+ 3. Add the hook to your Claude Code settings: +

+
+
${_buildHookSnippet('')}
+ +
+

+ Replace YOUR_API_KEY with your key after creating it. +

+ ⬇ Download cc-collector.py +
+
+ + + + `; + + document.getElementById('btn-new-key').onclick = () => { + document.getElementById('new-key-modal').classList.add('open'); + document.getElementById('key-result').style.display = 'none'; + document.getElementById('key-error').style.display = 'none'; + document.getElementById('btn-create-key').style.display = ''; + }; + + document.getElementById('btn-cancel-key').onclick = () => { + document.getElementById('new-key-modal').classList.remove('open'); + load(container); + }; + + document.getElementById('btn-create-key').onclick = async () => { + const label = document.getElementById('key-label').value.trim() || 'My Machine'; + const errEl = document.getElementById('key-error'); + errEl.style.display = 'none'; + try { + const key = await Api.post('/api/keys', { label }); + document.getElementById('key-raw').textContent = key.raw_key; + document.getElementById('key-result').style.display = 'block'; + document.getElementById('btn-create-key').style.display = 'none'; + // Update hook snippet + document.getElementById('hook-snippet').textContent = _buildHookSnippet(key.raw_key); + } catch (e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } + }; + + // Revoke buttons + container.querySelectorAll('[data-id]').forEach(btn => { + btn.onclick = async () => { + if (!confirm('Revoke this key?')) return; + try { + await Api.del(`/api/keys/${btn.dataset.id}`); + await load(container); + } catch (e) { + alert(e.message); + } + }; + }); + + } catch (e) { + container.innerHTML = `
⚠️
${e.message}
`; + } + } + + function _buildHookSnippet(key) { + const server = window.location.origin + '/cc-dashboard'; + return `{ + "hooks": { + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "CC_API_KEY=${key || 'YOUR_API_KEY'} CC_SERVER=${server} python3 ~/.claude/cc-collector.py 2>/dev/null || true", + "async": true, + "statusMessage": "Syncing to CC Dashboard…" + }] + }] + } +}`; + } + + return { render }; +})(); + +function copyHook() { + navigator.clipboard.writeText(document.getElementById('hook-snippet').textContent); +} + +function copyRawKey() { + navigator.clipboard.writeText(document.getElementById('key-raw').textContent); +} diff --git a/src/static/js/pages/live.js b/src/static/js/pages/live.js new file mode 100644 index 0000000..5a73e8e --- /dev/null +++ b/src/static/js/pages/live.js @@ -0,0 +1,62 @@ +const LivePage = (() => { + const MAX_ITEMS = 50; + let _feedEl = null; + + function render(container) { + container.innerHTML = ` +
+
+

Live Feed

+
+ + Connecting… +
+
+
+
+
+
+ Waiting for Claude Code sessions… +
+
+
+
+ `; + + _feedEl = document.getElementById('live-feed'); + SSE.setDot(document.getElementById('live-dot')); + + SSE.on('session_update', (data) => { + document.getElementById('live-status').textContent = 'Live'; + _addItem(data); + }); + + SSE.on('*', () => { + document.getElementById('live-status').textContent = 'Live'; + }); + } + + function _addItem(data) { + if (!_feedEl) return; + const emptyEl = _feedEl.querySelector('.empty'); + if (emptyEl) emptyEl.remove(); + + const item = document.createElement('div'); + item.className = 'feed-item'; + item.innerHTML = ` +
+
+
Session synced — ${data.accepted || 0} new record${(data.accepted || 0) !== 1 ? 's' : ''}
+
Dashboard data updated
+
+
${new Date().toLocaleTimeString()}
+ `; + _feedEl.insertBefore(item, _feedEl.firstChild); + + // Keep max items + const items = _feedEl.querySelectorAll('.feed-item'); + if (items.length > MAX_ITEMS) items[items.length - 1].remove(); + } + + return { render }; +})(); diff --git a/src/static/js/pages/project-detail.js b/src/static/js/pages/project-detail.js new file mode 100644 index 0000000..7549da5 --- /dev/null +++ b/src/static/js/pages/project-detail.js @@ -0,0 +1,86 @@ +const ProjectDetailPage = (() => { + async function render(container, projectId) { + container.innerHTML = `
Loading…
`; + try { + const detail = await Api.get(`/api/dashboard/project/${projectId}`); + const p = detail.project; + + container.innerHTML = ` +
+
+
+ +

${p.display_name}

+ ${p.root_path ? `
${p.root_path}
` : ''} +
+
+ +
+
+

Daily Hours

+
+
+
+ +
+
+

Top Files

+ + + + ${detail.top_files.length ? detail.top_files.map(f => ` + + `).join('') : ''} + +
FileEdits
${f.file}${f.count}
No data
+
+
+

Tool Usage

+ + + + ${detail.top_tools.length ? detail.top_tools.map(t => ` + + `).join('') : ''} + +
ToolUses
${t.tool}${t.count}
No data
+
+
+ +
+

Sessions

+ + + + + + ${detail.sessions.map(s => ` + + + + + + + + `).join('')} + +
DateTimeHoursMsgsWork Summary
${s.date} + ${new Date(s.start_at).toLocaleTimeString('en',{hour:'2-digit',minute:'2-digit'})}–${new Date(s.end_at).toLocaleTimeString('en',{hour:'2-digit',minute:'2-digit'})} + ${s.active_hours.toFixed(1)}h${s.message_count}${s.work_summary || '—'}
+
+
+ `; + + if (detail.daily.length > 0) { + makeChart('proj-daily', ChartDefs.lineArea( + detail.daily.map(d => d.date), + detail.daily.map(d => d.hours), + )); + } + } catch (e) { + container.innerHTML = `
⚠️
${e.message}
`; + } + } + + return { render }; +})(); diff --git a/src/static/js/pages/projects.js b/src/static/js/pages/projects.js new file mode 100644 index 0000000..6cd1190 --- /dev/null +++ b/src/static/js/pages/projects.js @@ -0,0 +1,48 @@ +const ProjectsPage = (() => { + async function render(container) { + container.innerHTML = `
Loading projects…
`; + try { + const projects = await Api.get('/api/dashboard/projects'); + container.innerHTML = ` +
+
+

Projects

+ ${projects.length} project${projects.length !== 1 ? 's' : ''} +
+
+ + + + + + + + + + + + + ${projects.map(p => ` + + + + + + + + + `).join('')} + +
ProjectTotal HoursSessionsWorking DaysLast Active
${p.display_name}${p.total_hours.toFixed(1)}h${p.session_count}${p.working_days}${p.last_active || '—'} + +
+
+
+ `; + } catch (e) { + container.innerHTML = `
⚠️
${e.message}
`; + } + } + + return { render }; +})(); diff --git a/src/static/js/pages/settings.js b/src/static/js/pages/settings.js new file mode 100644 index 0000000..0fc3f61 --- /dev/null +++ b/src/static/js/pages/settings.js @@ -0,0 +1,91 @@ +const SettingsPage = (() => { + async function render(container) { + let me; + try { me = await Api.get('/api/auth/me'); } catch { return; } + + container.innerHTML = ` +
+

Settings

+ + +
+

Change Password

+
+
+
+
+ +
+ + +
+

Daily Overhead Hours

+

+ Extra hours added per working day to account for non-Claude work (deploys, reviews, meetings). +

+
+ + +
+ +
+ + +
+

Account

+
+
+
Email: ${me.email}
+
Username: ${me.username}
+
Role: ${me.role}
+
+
+ +
+
+ `; + + document.getElementById('btn-cp').onclick = async () => { + const msgEl = document.getElementById('cp-msg'); + msgEl.style.display = 'none'; + try { + await Api.post('/api/auth/change-password', { + current_password: document.getElementById('cp-current').value, + new_password: document.getElementById('cp-new').value, + }); + msgEl.textContent = 'Password updated.'; + msgEl.style.background = 'rgba(34,197,94,.12)'; + msgEl.style.borderColor = 'rgba(34,197,94,.3)'; + msgEl.style.color = 'var(--success)'; + msgEl.style.display = 'block'; + } catch (e) { + msgEl.style.background = ''; + msgEl.style.borderColor = ''; + msgEl.style.color = ''; + msgEl.textContent = e.message; + msgEl.style.display = 'block'; + } + }; + + document.getElementById('btn-overhead').onclick = async () => { + const val = parseFloat(document.getElementById('overhead-val').value); + const msgEl = document.getElementById('oh-msg'); + try { + // Admin endpoint to update own user + await Api.put(`/api/admin/users/${me.id}`, { daily_overhead_hours: val }); + msgEl.textContent = '✓ Saved'; + msgEl.style.color = 'var(--success)'; + msgEl.style.display = 'block'; + setTimeout(() => { msgEl.style.display = 'none'; }, 2000); + } catch { + // Fallback: user updating themselves isn't admin — need a separate endpoint + // For now show a note + msgEl.textContent = 'Only admins can update this. Ask your admin.'; + msgEl.style.color = 'var(--text-muted)'; + msgEl.style.display = 'block'; + } + }; + } + + return { render }; +})(); diff --git a/src/static/js/sse.js b/src/static/js/sse.js new file mode 100644 index 0000000..063d914 --- /dev/null +++ b/src/static/js/sse.js @@ -0,0 +1,61 @@ +/** + * SSE client with auto-reconnect. + * Heartbeat ": heartbeat" comments keep connection alive through 30s LB timeout. + */ +const SSE = (() => { + const BASE = window.CC_BASE || ''; + let _es = null; + let _handlers = {}; + let _dotEl = null; + let _reconnectTimer = null; + + function setDot(el) { _dotEl = el; } + + function _setState(state) { + if (!_dotEl) return; + _dotEl.className = 'sse-dot ' + state; + _dotEl.title = state === 'connected' ? 'Live updates active' : state === 'error' ? 'Disconnected — retrying' : 'Connecting…'; + } + + function on(type, handler) { _handlers[type] = handler; } + + function connect() { + if (_es) return; + _setState(''); + + // EventSource doesn't support custom headers — send token as query param + const token = Api.getAccessToken(); + // We use a small wrapper: GET /api/events with Bearer via query won't work with + // standard EventSource. Instead we fetch a one-time SSE ticket or reuse JWT from + // localStorage. For simplicity, pass token via URL param and validate on server. + _es = new EventSource(`${BASE}/api/events?token=${encodeURIComponent(token)}`); + + _es.onopen = () => { _setState('connected'); }; + + _es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + const handler = _handlers[data.type]; + if (handler) handler(data); + const allHandler = _handlers['*']; + if (allHandler) allHandler(data); + } catch { /* ignore parse errors */ } + }; + + _es.onerror = () => { + _setState('error'); + _es.close(); + _es = null; + if (_reconnectTimer) clearTimeout(_reconnectTimer); + _reconnectTimer = setTimeout(connect, 4000); + }; + } + + function disconnect() { + if (_reconnectTimer) clearTimeout(_reconnectTimer); + if (_es) { _es.close(); _es = null; } + _setState(''); + } + + return { connect, disconnect, on, setDot }; +})();