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:
parent
074cf56376
commit
7b30880d44
42 changed files with 3810 additions and 39 deletions
15
.env.example
Normal file
15
.env.example
Normal 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
53
.gitignore
vendored
|
|
@ -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
14
Dockerfile
Normal 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
38
alembic/alembic.ini
Normal 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
54
alembic/env.py
Normal 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()
|
||||
107
alembic/versions/0001_initial.py
Normal file
107
alembic/versions/0001_initial.py
Normal 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
24
apache.conf
Normal 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
30
docker-compose.yml
Normal 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
11
requirements.txt
Normal 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
43
scripts/create_admin.py
Normal 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
0
src/__init__.py
Normal file
109
src/auth.py
Normal file
109
src/auth.py
Normal 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
30
src/config.py
Normal 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
22
src/database.py
Normal 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
49
src/main.py
Normal 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
106
src/models.py
Normal 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
0
src/routers/__init__.py
Normal file
82
src/routers/admin.py
Normal file
82
src/routers/admin.py
Normal 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
60
src/routers/auth.py
Normal 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
370
src/routers/dashboard.py
Normal 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
62
src/routers/events.py
Normal 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
98
src/routers/ingest.py
Normal 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
41
src/routers/keys.py
Normal 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
35
src/routers/projects.py
Normal 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
194
src/schemas.py
Normal 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
0
src/services/__init__.py
Normal file
67
src/services/aggregator.py
Normal file
67
src/services/aggregator.py
Normal 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
30
src/services/event_bus.py
Normal 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()
|
||||
322
src/static/collector/cc-collector.py
Normal file
322
src/static/collector/cc-collector.py
Normal 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
448
src/static/css/app.css
Normal 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
31
src/static/index.html
Normal 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
131
src/static/js/api.js
Normal 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
159
src/static/js/app.js
Normal 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
234
src/static/js/charts.js
Normal 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);
|
||||
}
|
||||
110
src/static/js/pages/admin.js
Normal file
110
src/static/js/pages/admin.js
Normal 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 };
|
||||
})();
|
||||
172
src/static/js/pages/dashboard.js
Normal file
172
src/static/js/pages/dashboard.js
Normal 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
150
src/static/js/pages/keys.js
Normal 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);
|
||||
}
|
||||
62
src/static/js/pages/live.js
Normal file
62
src/static/js/pages/live.js
Normal 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 };
|
||||
})();
|
||||
86
src/static/js/pages/project-detail.js
Normal file
86
src/static/js/pages/project-detail.js
Normal 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 };
|
||||
})();
|
||||
48
src/static/js/pages/projects.js
Normal file
48
src/static/js/pages/projects.js
Normal 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 };
|
||||
})();
|
||||
91
src/static/js/pages/settings.js
Normal file
91
src/static/js/pages/settings.js
Normal 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
61
src/static/js/sse.js
Normal 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 };
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue