Initial commit — CC Dashboard v1.0

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-26 12:54:13 +00:00
parent 074cf56376
commit 7b30880d44
42 changed files with 3810 additions and 39 deletions

15
.env.example Normal file
View file

@ -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

53
.gitignore vendored
View file

@ -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

14
Dockerfile Normal file
View file

@ -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"]

38
alembic/alembic.ini Normal file
View file

@ -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

54
alembic/env.py Normal file
View file

@ -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()

View file

@ -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")

24
apache.conf Normal file
View file

@ -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
<Location /cc-dashboard/>
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"
</Location>

30
docker-compose.yml Normal file
View file

@ -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:

11
requirements.txt Normal file
View file

@ -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

43
scripts/create_admin.py Normal file
View file

@ -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())

0
src/__init__.py Normal file
View file

109
src/auth.py Normal file
View file

@ -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)]

30
src/config.py Normal file
View file

@ -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()

22
src/database.py Normal file
View file

@ -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

49
src/main.py Normal file
View file

@ -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")

106
src/models.py Normal file
View file

@ -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")

0
src/routers/__init__.py Normal file
View file

82
src/routers/admin.py Normal file
View file

@ -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],
)

60
src/routers/auth.py Normal file
View file

@ -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

370
src/routers/dashboard.py Normal file
View file

@ -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],
)

62
src/routers/events.py Normal file
View file

@ -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",
},
)

98
src/routers/ingest.py Normal file
View file

@ -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)

41
src/routers/keys.py Normal file
View file

@ -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()

35
src/routers/projects.py Normal file
View file

@ -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

194
src/schemas.py Normal file
View file

@ -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]

0
src/services/__init__.py Normal file
View file

View file

@ -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()

30
src/services/event_bus.py Normal file
View file

@ -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()

View file

@ -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)

448
src/static/css/app.css Normal file
View file

@ -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; } }

31
src/static/index.html Normal file
View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CC Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%23FFC407'/><text x='16' y='22' text-anchor='middle' font-size='18' font-family='Montserrat,sans-serif' font-weight='800' fill='%23000'>C</text></svg>">
<link rel="stylesheet" href="/cc-dashboard/static/css/app.css">
<!-- Tailwind CDN for utility classes -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Chart.js with time adapter -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
</head>
<body>
<div id="app"></div>
<!-- JS load order matters -->
<script src="/cc-dashboard/static/js/api.js"></script>
<script src="/cc-dashboard/static/js/sse.js"></script>
<script src="/cc-dashboard/static/js/charts.js"></script>
<script src="/cc-dashboard/static/js/pages/dashboard.js"></script>
<script src="/cc-dashboard/static/js/pages/projects.js"></script>
<script src="/cc-dashboard/static/js/pages/project-detail.js"></script>
<script src="/cc-dashboard/static/js/pages/keys.js"></script>
<script src="/cc-dashboard/static/js/pages/live.js"></script>
<script src="/cc-dashboard/static/js/pages/admin.js"></script>
<script src="/cc-dashboard/static/js/pages/settings.js"></script>
<script src="/cc-dashboard/static/js/app.js"></script>
</body>
</html>

131
src/static/js/api.js Normal file
View file

@ -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 };
})();

159
src/static/js/app.js Normal file
View file

@ -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 = `
<div id="login-page">
<div class="login-box">
<div class="logo">CC<span class="accent">.</span>Dashboard</div>
<div id="login-error" class="error-msg"></div>
<div class="form-group">
<label>Email</label>
<input type="email" id="login-email" placeholder="you@example.com" autocomplete="email">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
</div>
<button class="btn btn-primary" style="width:100%;justify-content:center;margin-top:8px" id="btn-login">
Sign In
</button>
</div>
</div>
`;
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 = `
<div class="sidebar">
<div class="sidebar-logo">
<span>CC<span class="accent">.</span>Dashboard</span>
</div>
<nav class="sidebar-nav" id="sidebar-nav">
${navItems.map(n => `
<button class="nav-item" data-page="${n.id}" onclick="App.navigate('${n.id}')">
<span class="icon">${n.icon}</span>
${n.label}
</button>
`).join('')}
</nav>
<div class="sidebar-bottom">
<div class="sidebar-user">
<strong>${_currentUser?.username || ''}</strong>
${_currentUser?.email || ''}
</div>
</div>
</div>
<div class="main">
<div class="topbar">
<h1 id="page-title">Dashboard</h1>
<span class="sse-dot" id="sse-dot"></span>
</div>
<div class="content" id="page-content"></div>
</div>
`;
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);
});

234
src/static/js/charts.js Normal file
View file

@ -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);
}

View file

@ -0,0 +1,110 @@
const AdminPage = (() => {
async function render(container) {
container.innerHTML = `<div class="page"><div class="loading">Loading…</div></div>`;
try {
const stats = await Api.get('/api/admin/stats');
container.innerHTML = `
<div class="page">
<div class="section-header">
<h2>Admin</h2>
<button class="btn btn-primary btn-sm" id="btn-new-user">+ New User</button>
</div>
<div class="kpi-grid" style="margin-bottom:24px">
<div class="kpi-card"><div class="label">Total Users</div><div class="value">${stats.total_users}</div></div>
<div class="kpi-card"><div class="label">Active Users</div><div class="value accent">${stats.active_users}</div></div>
<div class="kpi-card"><div class="label">Total Sessions</div><div class="value">${stats.total_sessions}</div></div>
<div class="kpi-card"><div class="label">Total Hours</div><div class="value">${stats.total_hours.toFixed(1)}h</div></div>
</div>
<div class="table-card">
<h3>Users</h3>
<table>
<thead>
<tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th></th></tr>
</thead>
<tbody id="users-tbody">
${stats.users.map(u => _userRow(u)).join('')}
</tbody>
</table>
</div>
</div>
<!-- New user modal -->
<div class="modal-overlay" id="new-user-modal">
<div class="modal">
<h2>Create User</h2>
<div class="form-group"><label>Email</label><input type="email" id="nu-email" placeholder="user@example.com"></div>
<div class="form-group"><label>Username</label><input type="text" id="nu-username" placeholder="johndoe"></div>
<div class="form-group"><label>Password</label><input type="password" id="nu-password" placeholder="min 8 chars"></div>
<div class="form-group">
<label>Role</label>
<select id="nu-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div id="nu-error" class="error-msg"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-ghost" id="btn-cancel-user">Cancel</button>
<button class="btn btn-primary" id="btn-save-user">Create</button>
</div>
</div>
</div>
`;
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 = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
}
}
function _userRow(u) {
return `<tr>
<td><strong>${u.username}</strong></td>
<td style="color:var(--text-muted)">${u.email}</td>
<td>${u.role === 'admin' ? '<span class="badge badge-accent">Admin</span>' : '<span class="badge badge-muted">User</span>'}</td>
<td>${u.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}</td>
<td style="color:var(--text-muted);font-size:12px">${new Date(u.created_at).toLocaleDateString()}</td>
<td><button class="btn btn-ghost btn-sm" data-toggle="${u.id}" data-active="${u.is_active}">${u.is_active ? 'Deactivate' : 'Activate'}</button></td>
</tr>`;
}
return { render };
})();

View file

@ -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 = `
<div class="page">
<div class="section-header">
<h2>Dashboard</h2>
<div style="display:flex;gap:8px;align-items:center">
<div class="date-range">
<span style="color:var(--text-muted);font-size:11px">From</span>
<input type="date" id="dash-from" value="${_from}">
<span style="color:var(--text-muted);font-size:11px">To</span>
<input type="date" id="dash-to" value="${_to}">
</div>
<button class="btn btn-primary btn-sm" id="dash-apply">Apply</button>
</div>
</div>
<div class="kpi-grid" id="kpi-grid">
${[...Array(8)].map(() => `<div class="kpi-card"><div class="label">...</div><div class="value" style="height:28px;background:var(--surface2);border-radius:4px;width:80px"></div></div>`).join('')}
</div>
<div class="chart-grid">
<div class="chart-card">
<h3>Hours by Project</h3>
<div class="chart-wrap"><canvas id="chart-proj-bar"></canvas></div>
</div>
<div class="chart-card">
<h3>Project Share</h3>
<div class="chart-wrap"><canvas id="chart-donut"></canvas></div>
</div>
<div class="chart-card wide">
<h3>Daily Activity (last 30 days)</h3>
<div class="chart-wrap"><canvas id="chart-daily"></canvas></div>
</div>
<div class="chart-card">
<h3>Monthly Trend</h3>
<div class="chart-wrap"><canvas id="chart-monthly"></canvas></div>
</div>
<div class="chart-card">
<h3>Day of Week</h3>
<div class="chart-wrap"><canvas id="chart-dow"></canvas></div>
</div>
<div class="chart-card">
<h3>Tools Used</h3>
<div class="chart-wrap"><canvas id="chart-tools"></canvas></div>
</div>
<div class="chart-card">
<h3>Today's Sessions</h3>
<div class="chart-wrap tall" id="timeline-wrap"><canvas id="chart-timeline"></canvas></div>
</div>
</div>
</div>
`;
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 => `
<div class="kpi-card">
<div class="label">${c.label}</div>
<div class="value${c.accent ? ' accent' : ''}" style="font-size:${c.label === 'Top Project' ? '16px' : '28px'}">${c.value}</div>
${c.sub !== undefined ? `<div class="sub">${c.sub}</div>` : ''}
</div>
`).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 = '<div class="empty" style="padding:20px">No sessions today</div>';
}
}
return { render };
})();

150
src/static/js/pages/keys.js Normal file
View file

@ -0,0 +1,150 @@
const KeysPage = (() => {
let _keys = [];
async function render(container) {
container.innerHTML = `<div class="page"><div class="loading">Loading…</div></div>`;
await load(container);
}
async function load(container) {
try {
_keys = await Api.get('/api/keys');
container.innerHTML = `
<div class="page">
<div class="section-header">
<h2>API Keys</h2>
<button class="btn btn-primary btn-sm" id="btn-new-key">+ New Key</button>
</div>
<div class="table-card" style="margin-bottom:24px">
<table>
<thead><tr><th>Label</th><th>Prefix</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
${_keys.length ? _keys.map(k => `
<tr>
<td><strong>${k.label}</strong></td>
<td><code style="color:var(--accent);font-size:12px">${k.key_prefix}</code></td>
<td style="color:var(--text-muted);font-size:12px">${k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never'}</td>
<td>${k.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-muted">Revoked</span>'}</td>
<td>${k.is_active ? `<button class="btn btn-danger btn-sm" data-id="${k.id}">Revoke</button>` : ''}</td>
</tr>
`).join('') : '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px">No API keys yet</td></tr>'}
</tbody>
</table>
</div>
<div class="chart-card" style="max-width:700px">
<h3>Hook Setup Instructions</h3>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
1. Create an API key above.<br>
2. Download <strong>cc-collector.py</strong> and save to <code style="color:var(--accent)">~/.claude/cc-collector.py</code><br>
3. Add the hook to your Claude Code settings:
</p>
<div style="position:relative">
<pre class="code-block" id="hook-snippet">${_buildHookSnippet('')}</pre>
<button class="copy-btn" onclick="copyHook()">Copy</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:12px">
Replace <code style="color:var(--accent)">YOUR_API_KEY</code> with your key after creating it.
</p>
<a href="/cc-dashboard/static/collector/cc-collector.py" download class="btn btn-ghost btn-sm" style="margin-top:12px"> Download cc-collector.py</a>
</div>
</div>
<!-- New key modal -->
<div class="modal-overlay" id="new-key-modal">
<div class="modal">
<h2>Create API Key</h2>
<div class="form-group">
<label>Label</label>
<input type="text" id="key-label" placeholder="My MacBook" value="My Machine">
</div>
<div id="key-result" style="display:none;margin-bottom:16px">
<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Your key (shown once copy it now):</p>
<div style="position:relative">
<pre class="code-block" id="key-raw" style="word-break:break-all"></pre>
<button class="copy-btn" onclick="copyRawKey()">Copy</button>
</div>
</div>
<div id="key-error" class="error-msg"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-ghost" id="btn-cancel-key">Cancel</button>
<button class="btn btn-primary" id="btn-create-key">Create</button>
</div>
</div>
</div>
`;
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 = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
}
}
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);
}

View file

@ -0,0 +1,62 @@
const LivePage = (() => {
const MAX_ITEMS = 50;
let _feedEl = null;
function render(container) {
container.innerHTML = `
<div class="page">
<div class="section-header">
<h2>Live Feed</h2>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-muted)">
<span class="sse-dot" id="live-dot"></span>
<span id="live-status">Connecting</span>
</div>
</div>
<div class="chart-card">
<div id="live-feed">
<div class="empty" style="padding:40px">
<div class="big"></div>
Waiting for Claude Code sessions
</div>
</div>
</div>
</div>
`;
_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 = `
<div class="feed-dot"></div>
<div class="feed-content">
<div class="project">Session synced ${data.accepted || 0} new record${(data.accepted || 0) !== 1 ? 's' : ''}</div>
<div class="summary">Dashboard data updated</div>
</div>
<div class="feed-time">${new Date().toLocaleTimeString()}</div>
`;
_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 };
})();

View file

@ -0,0 +1,86 @@
const ProjectDetailPage = (() => {
async function render(container, projectId) {
container.innerHTML = `<div class="page"><div class="loading">Loading…</div></div>`;
try {
const detail = await Api.get(`/api/dashboard/project/${projectId}`);
const p = detail.project;
container.innerHTML = `
<div class="page">
<div class="section-header">
<div>
<button class="btn btn-ghost btn-sm" onclick="App.navigate('projects')" style="margin-bottom:8px"> Back</button>
<h2>${p.display_name}</h2>
${p.root_path ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">${p.root_path}</div>` : ''}
</div>
</div>
<div class="chart-grid">
<div class="chart-card wide">
<h3>Daily Hours</h3>
<div class="chart-wrap"><canvas id="proj-daily"></canvas></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
<div class="table-card">
<h3>Top Files</h3>
<table>
<thead><tr><th>File</th><th>Edits</th></tr></thead>
<tbody>
${detail.top_files.length ? detail.top_files.map(f => `
<tr><td>${f.file}</td><td><span class="badge badge-accent">${f.count}</span></td></tr>
`).join('') : '<tr><td colspan="2" style="color:var(--text-muted);text-align:center;padding:20px">No data</td></tr>'}
</tbody>
</table>
</div>
<div class="table-card">
<h3>Tool Usage</h3>
<table>
<thead><tr><th>Tool</th><th>Uses</th></tr></thead>
<tbody>
${detail.top_tools.length ? detail.top_tools.map(t => `
<tr><td>${t.tool}</td><td><span class="badge badge-muted">${t.count}</span></td></tr>
`).join('') : '<tr><td colspan="2" style="color:var(--text-muted);text-align:center;padding:20px">No data</td></tr>'}
</tbody>
</table>
</div>
</div>
<div class="table-card">
<h3>Sessions</h3>
<table>
<thead>
<tr><th>Date</th><th>Time</th><th>Hours</th><th>Msgs</th><th>Work Summary</th></tr>
</thead>
<tbody>
${detail.sessions.map(s => `
<tr>
<td>${s.date}</td>
<td style="color:var(--text-muted);font-size:12px">
${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'})}
</td>
<td><span class="badge badge-accent">${s.active_hours.toFixed(1)}h</span></td>
<td>${s.message_count}</td>
<td style="font-size:12px;color:var(--text-muted);max-width:400px;white-space:pre-wrap">${s.work_summary || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
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 = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
}
}
return { render };
})();

View file

@ -0,0 +1,48 @@
const ProjectsPage = (() => {
async function render(container) {
container.innerHTML = `<div class="page"><div class="loading">Loading projects…</div></div>`;
try {
const projects = await Api.get('/api/dashboard/projects');
container.innerHTML = `
<div class="page">
<div class="section-header">
<h2>Projects</h2>
<span style="color:var(--text-muted);font-size:13px">${projects.length} project${projects.length !== 1 ? 's' : ''}</span>
</div>
<div class="table-card">
<table>
<thead>
<tr>
<th>Project</th>
<th>Total Hours</th>
<th>Sessions</th>
<th>Working Days</th>
<th>Last Active</th>
<th></th>
</tr>
</thead>
<tbody>
${projects.map(p => `
<tr>
<td><strong>${p.display_name}</strong></td>
<td><span class="badge badge-accent">${p.total_hours.toFixed(1)}h</span></td>
<td>${p.session_count}</td>
<td>${p.working_days}</td>
<td style="color:var(--text-muted)">${p.last_active || '—'}</td>
<td>
<button class="btn btn-ghost btn-sm" onclick="App.navigate('project', '${p.project_id}')">View </button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
}
}
return { render };
})();

View file

@ -0,0 +1,91 @@
const SettingsPage = (() => {
async function render(container) {
let me;
try { me = await Api.get('/api/auth/me'); } catch { return; }
container.innerHTML = `
<div class="page">
<div class="section-header"><h2>Settings</h2></div>
<!-- Change password -->
<div class="chart-card" style="max-width:480px;margin-bottom:16px">
<h3>Change Password</h3>
<div style="height:16px"></div>
<div class="form-group"><label>Current Password</label><input type="password" id="cp-current"></div>
<div class="form-group"><label>New Password</label><input type="password" id="cp-new" placeholder="min 8 chars"></div>
<div id="cp-msg" class="error-msg"></div>
<button class="btn btn-primary btn-sm" id="btn-cp">Update Password</button>
</div>
<!-- Daily overhead -->
<div class="chart-card" style="max-width:480px;margin-bottom:16px">
<h3>Daily Overhead Hours</h3>
<p style="font-size:12px;color:var(--text-muted);margin:12px 0">
Extra hours added per working day to account for non-Claude work (deploys, reviews, meetings).
</p>
<div style="display:flex;align-items:center;gap:12px">
<input type="number" id="overhead-val" value="${me.daily_overhead_hours}" min="0" max="12" step="0.5" style="width:100px">
<button class="btn btn-primary btn-sm" id="btn-overhead">Save</button>
</div>
<div id="oh-msg" style="font-size:12px;margin-top:8px;display:none"></div>
</div>
<!-- Account info -->
<div class="chart-card" style="max-width:480px">
<h3>Account</h3>
<div style="height:12px"></div>
<div style="font-size:13px;display:grid;gap:8px">
<div><span style="color:var(--text-muted)">Email:</span> ${me.email}</div>
<div><span style="color:var(--text-muted)">Username:</span> ${me.username}</div>
<div><span style="color:var(--text-muted)">Role:</span> <span class="badge ${me.role === 'admin' ? 'badge-accent' : 'badge-muted'}">${me.role}</span></div>
</div>
<div style="height:16px"></div>
<button class="btn btn-danger btn-sm" onclick="Api.logout()">Sign Out</button>
</div>
</div>
`;
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 };
})();

61
src/static/js/sse.js Normal file
View file

@ -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 };
})();