commit f732cb080bbd670c3c768edeee98def61c333710 Author: menzelj Date: Sun Jun 7 16:04:58 2026 +0000 Initial commit: StackPilot Phase 1 (Core) Self-hosted Docker Compose manager. - Backend: FastAPI + docker-py + SQLite (JWT auth, file-first stacks, lifecycle, live status, WebSocket logs, docker-run converter, audit log) - Frontend: React + Vite + Tailwind (login/setup, dashboard, stacks, stack detail, Monaco editor, dark/light theme) - Deployment: docker-compose.yml, Dockerfiles, nginx reverse proxy Co-Authored-By: Claude Opus 4.8 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7896d4f --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Required: a long random secret for signing JWTs. +# Generate with: openssl rand -base64 48 +SECRET_KEY=change-me-to-a-long-random-string + +# Host directory where stack folders (compose.yaml + .env) are stored. +# This MUST be the same path on the host and is bind-mounted into the backend. +STACKS_HOST_DIR=./data/stacks + +# Allowed CORS origin(s) for the API (comma separated). The bundled frontend +# proxies /api, so this only matters if you call the API from another origin. +CORS_ORIGINS=http://localhost:5009 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..532eb29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Node / frontend +node_modules/ +frontend/dist/ + +# App data / secrets +data/ +*.db +.env + +# Compose backups +**/.compose.yaml.bak +**/compose.yaml.bak + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..db888c7 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# StackPilot + +A self-hosted Docker Compose manager for power users and homelab enthusiasts — +as intuitive as Dockge, as capable as Portainer for Compose workflows. + +> **Status:** Phase 1 (Core) complete. Volume/GPU wizards, image-update checks, +> templates, multi-host agents and backups land in later phases. + +## What works today (Phase 1) + +- **File-first stacks** — every stack is a plain `compose.yaml` (+ optional `.env`) + on disk. The DB only stores metadata; nothing is locked in. +- **Auth** — JWT access/refresh tokens, bcrypt hashing, admin/user roles, and a + first-launch setup wizard that creates the initial admin account. +- **Stack lifecycle** — create, edit, clone, delete, and `up / down / start / + stop / restart / pull / update` via `docker compose`. +- **Live status** — running / partial / stopped / error / updating, computed from + Docker container labels. +- **Real-time logs** — streamed over WebSocket, color-coded per service. +- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` → + compose** converter. +- **Dashboard** — system resource bar, stack grid with quick actions, and a + recent-activity audit feed. +- **Auto-discovery** — stacks created outside the UI (any folder under the stacks + dir containing a compose file) are picked up automatically. +- **Dark / light theme.** + +## Architecture + +``` +frontend (React + Vite + Tailwind, served by nginx) + │ proxies /api and /ws + ▼ +backend (FastAPI + docker-py + SQLite) + │ docker-py + `docker compose` CLI + ▼ +Docker Engine (via /var/run/docker.sock — never exposed to the browser) +``` + +## Quick start + +```bash +cd stackpilot +cp .env.example .env +# edit .env and set a strong SECRET_KEY: openssl rand -base64 48 +docker compose up -d --build +``` + +Open and complete the first-launch setup wizard to create +your admin account. + +### Configuration + +All backend settings are environment variables (see `backend/config.py`). The +most important ones: + +| Variable | Default | Purpose | +|-----------------|--------------------|-------------------------------------------| +| `SECRET_KEY` | _(auto, dev only)_ | JWT signing key — **set this in prod** | +| `STACKS_DIR` | `/opt/stacks` | Where stack folders live (in-container) | +| `DATA_DIR` | `/data` | SQLite DB + app data | +| `CORS_ORIGINS` | localhost | Allowed API origins (comma separated) | + +The host path for stacks is set via `STACKS_HOST_DIR` in `.env`. + +## Local development + +Backend: + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +SECRET_KEY=dev STACKS_DIR=../data/stacks DATA_DIR=../data uvicorn main:app --reload --port 5008 +``` + +Frontend (proxies to the backend on :5008): + +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +## API surface (Phase 1) + +``` +POST /api/auth/setup | login | refresh GET /api/auth/me | needs-setup +GET /api/stacks POST /api/stacks +GET /api/stacks/{id} PUT /api/stacks/{id} DELETE /api/stacks/{id} +POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone} +GET /api/stacks/{id}/logs GET /api/stacks/{id}/export +POST /api/stacks/convert (docker run → compose) +GET /api/system/info GET /api/audit +WS /ws/logs/{stack_id}[/{service}] WS /ws/events +``` + +## Security notes + +- The Docker socket is only ever touched by the backend process; it is never + proxied to the browser. +- Login is rate-limited (10/min/IP). +- Compose files are backed up to `*.bak` before every overwrite. +- Generated YAML never includes the obsolete `version:` field and uses Compose v2 + (`docker compose`) syntax. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..836f13d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.git +data +*.db +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fe49f83 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +# Docker CLI + compose plugin are required for lifecycle commands. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV STACKS_DIR=/opt/stacks \ + DATA_DIR=/data \ + PORT=5008 + +EXPOSE 5008 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD curl -fsS http://localhost:5008/api/health || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008"] diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..b8e59c8 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,132 @@ +"""JWT auth + user management.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlmodel import Session, select + +from config import settings +from database import get_session +from models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + + +# --- password helpers --- + + +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) + + +# --- token helpers --- + + +def _create_token(sub: str, role: str, token_type: str, expires: timedelta) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": sub, + "role": role, + "type": token_type, + "iat": now, + "exp": now + expires, + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_access_token(user: User) -> str: + return _create_token( + user.username, + user.role, + "access", + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + +def create_refresh_token(user: User) -> str: + return _create_token( + user.username, + user.role, + "refresh", + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + ) + + +def decode_token(token: str, expected_type: str = "access") -> dict: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from exc + if payload.get("type") != expected_type: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Wrong token type", + ) + return payload + + +# --- user lookups --- + + +def get_user(session: Session, username: str) -> Optional[User]: + return session.exec(select(User).where(User.username == username)).first() + + +def authenticate(session: Session, username: str, password: str) -> Optional[User]: + user = get_user(session, username) + if not user or not user.is_active: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def users_exist(session: Session) -> bool: + return session.exec(select(User)).first() is not None + + +# --- FastAPI dependencies --- + + +def get_current_user( + token: Optional[str] = Depends(oauth2_scheme), + session: Session = Depends(get_session), +) -> User: + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = decode_token(token, "access") + user = get_user(session, payload.get("sub", "")) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + return user diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0d447d3 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,69 @@ +"""Application settings, loaded from environment variables.""" +from __future__ import annotations + +import secrets +from functools import lru_cache + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Paths + STACKS_DIR: str = "/opt/stackpilot/stacks" + DATA_DIR: str = "/opt/stackpilot/data" + + # Security + SECRET_KEY: str = "" # Auto-generated if empty (dev only); set in prod. + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + + # Update checker + UPDATE_CHECK_INTERVAL_MINUTES: int = 60 + + # Notifications (webhook URLs) + NOTIFY_WEBHOOKS: list[str] = [] + + # Docker + DOCKER_SOCKET: str = "/var/run/docker.sock" + HOST_PROC_PATH: str = "/host_proc" + + # Host browser sandbox roots + ALLOWED_BROWSE_ROOTS: list[str] = ["/", "/mnt", "/media", "/srv", "/opt"] + HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted + + # CORS + CORS_ORIGINS: list[str] = ["http://localhost:5009", "http://localhost:5173"] + + # Server + PORT: int = 5008 + + @field_validator("SECRET_KEY", mode="after") + @classmethod + def _ensure_secret(cls, v: str) -> str: + return v or secrets.token_urlsafe(48) + + @field_validator( + "NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before" + ) + @classmethod + def _split_csv(cls, v): + if isinstance(v, str): + v = v.strip() + if not v: + return [] + if v.startswith("["): # JSON list + return v + return [item.strip() for item in v.split(",") if item.strip()] + return v + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..bd7b731 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,31 @@ +"""SQLModel database setup.""" +from __future__ import annotations + +import os +from collections.abc import Generator + +from sqlmodel import Session, SQLModel, create_engine + +from config import settings + +os.makedirs(settings.DATA_DIR, exist_ok=True) +_DB_PATH = os.path.join(settings.DATA_DIR, "stackpilot.db") +_DB_URL = f"sqlite:///{_DB_PATH}" + +engine = create_engine( + _DB_URL, + echo=False, + connect_args={"check_same_thread": False}, +) + + +def init_db() -> None: + # Import models so they are registered on SQLModel.metadata. + import models # noqa: F401 + + SQLModel.metadata.create_all(engine) + + +def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session diff --git a/backend/docker_client.py b/backend/docker_client.py new file mode 100644 index 0000000..a0fc92a --- /dev/null +++ b/backend/docker_client.py @@ -0,0 +1,50 @@ +"""Singleton docker-py client wrapper. + +All Docker access goes through this module. The Docker socket is NEVER exposed +to the frontend. +""" +from __future__ import annotations + +import functools +from typing import Any + +import docker +from docker.errors import APIError, DockerException, NotFound + +from config import settings + + +class DockerError(Exception): + """Structured error surfaced to API callers.""" + + def __init__(self, error: str, detail: str = ""): + self.error = error + self.detail = detail + super().__init__(f"{error}: {detail}" if detail else error) + + +@functools.lru_cache(maxsize=1) +def get_client() -> docker.DockerClient: + try: + base_url = f"unix://{settings.DOCKER_SOCKET}" + client = docker.DockerClient(base_url=base_url) + client.ping() + return client + except DockerException as exc: # pragma: no cover - environment dependent + raise DockerError("docker_unavailable", str(exc)) from exc + + +def reset_client() -> None: + get_client.cache_clear() + + +def safe_call(fn, *args, **kwargs) -> Any: + """Run a docker-py call, converting exceptions into DockerError.""" + try: + return fn(*args, **kwargs) + except NotFound as exc: + raise DockerError("not_found", str(exc)) from exc + except APIError as exc: + raise DockerError("docker_api_error", str(exc)) from exc + except DockerException as exc: + raise DockerError("docker_error", str(exc)) from exc diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..52e693d --- /dev/null +++ b/backend/main.py @@ -0,0 +1,62 @@ +"""StackPilot backend — FastAPI application entry point.""" +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlmodel import Session + +from config import settings +from database import engine, init_db +from docker_client import DockerError +from routers import audit, auth, stacks, system, ws + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("stackpilot") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + # Register stacks that already exist on disk. + try: + with Session(engine) as session: + stacks.sync_discovered_stacks(session) + except Exception as exc: # noqa: BLE001 + logger.warning("Stack discovery failed: %s", exc) + logger.info("StackPilot backend ready on port %s", settings.PORT) + yield + + +app = FastAPI(title="StackPilot", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(DockerError) +async def docker_error_handler(_request: Request, exc: DockerError): + return JSONResponse( + status_code=502, + content={"error": exc.error, "detail": exc.detail}, + ) + + +app.include_router(auth.router) +app.include_router(stacks.router) +app.include_router(system.router) +app.include_router(audit.router) +app.include_router(ws.router) + + +@app.get("/api/health") +def health() -> dict: + return {"status": "ok"} diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..6dc387b --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,6 @@ +"""SQLModel table models. Importing this package registers all tables.""" +from models.audit import AuditLog +from models.stack import Stack +from models.user import User + +__all__ = ["User", "Stack", "AuditLog"] diff --git a/backend/models/audit.py b/backend/models/audit.py new file mode 100644 index 0000000..9cccb81 --- /dev/null +++ b/backend/models/audit.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class AuditLog(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user: str + action: str # e.g. "stack.start" + target: str # stack id / resource id + detail: Optional[str] = None + ip: Optional[str] = None + timestamp: datetime = Field(default_factory=_now, index=True) diff --git a/backend/models/stack.py b/backend/models/stack.py new file mode 100644 index 0000000..3e9849b --- /dev/null +++ b/backend/models/stack.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Stack(SQLModel, table=True): + # id is the stack directory name (slug) + id: str = Field(primary_key=True) + name: str + description: Optional[str] = None + stacks_dir_override: Optional[str] = None + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class StackCreate(SQLModel): + name: str + description: Optional[str] = None + yaml: Optional[str] = None # initial compose content + env: Optional[str] = None + + +class StackUpdate(SQLModel): + name: Optional[str] = None + description: Optional[str] = None + yaml: Optional[str] = None + env: Optional[str] = None + + +class StackCloneRequest(SQLModel): + name: str + + +class ConvertRequest(SQLModel): + command: str # a `docker run ...` string + + +class ConvertResponse(SQLModel): + yaml: str diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..39b33c0 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + username: str = Field(index=True, unique=True) + hashed_password: str + role: str = Field(default="user") # "admin" | "user" + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class UserRead(SQLModel): + id: int + username: str + role: str + is_active: bool + + +class UserCreate(SQLModel): + username: str + password: str + role: str = "admin" + + +class LoginRequest(SQLModel): + username: str + password: str + + +class TokenPair(SQLModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(SQLModel): + refresh_token: str diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..be3af9d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +docker==7.1.0 +sqlmodel==0.0.22 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.2.1 +python-multipart==0.0.20 +watchdog==6.0.0 +httpx==0.28.1 +PyYAML==6.0.2 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/audit.py b/backend/routers/audit.py new file mode 100644 index 0000000..09e8c34 --- /dev/null +++ b/backend/routers/audit.py @@ -0,0 +1,29 @@ +"""Audit log query endpoint.""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session, select + +from auth import get_current_user +from database import get_session +from models.audit import AuditLog +from models.user import User + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + + +@router.get("") +def list_audit( + limit: int = Query(100, le=500), + offset: int = 0, + stack_id: Optional[str] = None, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[AuditLog]: + stmt = select(AuditLog).order_by(AuditLog.timestamp.desc()) + if stack_id: + stmt = stmt.where(AuditLog.target == stack_id) + stmt = stmt.offset(offset).limit(limit) + return session.exec(stmt).all() diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..737550e --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,111 @@ +"""Authentication routes + first-launch setup wizard.""" +from __future__ import annotations + +import time +from collections import defaultdict, deque + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlmodel import Session + +import auth as auth_mod +from database import get_session +from models.user import ( + LoginRequest, + RefreshRequest, + TokenPair, + User, + UserCreate, + UserRead, +) +from services import audit_service + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +# Simple in-memory rate limiter for login (max 10 / minute / IP). +_LOGIN_HITS: dict[str, deque] = defaultdict(deque) +_RATE_LIMIT = 10 +_RATE_WINDOW = 60.0 + + +def _check_rate_limit(ip: str) -> None: + now = time.monotonic() + hits = _LOGIN_HITS[ip] + while hits and now - hits[0] > _RATE_WINDOW: + hits.popleft() + if len(hits) >= _RATE_LIMIT: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many login attempts, slow down.", + ) + hits.append(now) + + +def _tokens_for(user: User) -> TokenPair: + return TokenPair( + access_token=auth_mod.create_access_token(user), + refresh_token=auth_mod.create_refresh_token(user), + ) + + +@router.get("/needs-setup") +def needs_setup(session: Session = Depends(get_session)) -> dict: + """First-launch wizard check: True if no users exist yet.""" + return {"needs_setup": not auth_mod.users_exist(session)} + + +@router.post("/setup", response_model=TokenPair) +def setup( + body: UserCreate, session: Session = Depends(get_session) +) -> TokenPair: + if auth_mod.users_exist(session): + raise HTTPException(status_code=400, detail="Setup already completed") + user = User( + username=body.username, + hashed_password=auth_mod.hash_password(body.password), + role="admin", + ) + session.add(user) + session.commit() + session.refresh(user) + audit_service.record( + session, user=user.username, action="user.setup", target=user.username + ) + return _tokens_for(user) + + +@router.post("/login", response_model=TokenPair) +def login( + body: LoginRequest, + request: Request, + session: Session = Depends(get_session), +) -> TokenPair: + ip = request.client.host if request.client else "unknown" + _check_rate_limit(ip) + user = auth_mod.authenticate(session, body.username, body.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + audit_service.record( + session, user=user.username, action="auth.login", target=user.username, ip=ip + ) + return _tokens_for(user) + + +@router.post("/refresh", response_model=TokenPair) +def refresh( + body: RefreshRequest, session: Session = Depends(get_session) +) -> TokenPair: + payload = auth_mod.decode_token(body.refresh_token, "refresh") + user = auth_mod.get_user(session, payload.get("sub", "")) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" + ) + return _tokens_for(user) + + +@router.get("/me", response_model=UserRead) +def me(user: User = Depends(auth_mod.get_current_user)) -> User: + return user diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py new file mode 100644 index 0000000..f1ce25b --- /dev/null +++ b/backend/routers/stacks.py @@ -0,0 +1,334 @@ +"""Stack CRUD + lifecycle endpoints.""" +from __future__ import annotations + +import os +from dataclasses import asdict + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse +from sqlmodel import Session, select + +from auth import get_current_user, require_admin +from database import get_session +from docker_client import DockerError +from models.stack import ( + ConvertRequest, + ConvertResponse, + Stack, + StackCloneRequest, + StackCreate, + StackUpdate, +) +from models.user import User +from services import audit_service, compose_service +from services.convert_service import convert_docker_run + +router = APIRouter(prefix="/api/stacks", tags=["stacks"]) + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def sync_discovered_stacks(session: Session) -> None: + """Register any on-disk stacks not yet in the database.""" + known = {s.id for s in session.exec(select(Stack)).all()} + for stack_id in compose_service.discover_stacks(): + if stack_id not in known: + stack = Stack(id=stack_id, name=stack_id) + session.add(stack) + session.commit() + + +def _get_stack_or_404(session: Session, stack_id: str) -> Stack: + stack = session.get(Stack, stack_id) + if not stack: + raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found") + return stack + + +def _stack_summary(stack: Stack) -> dict: + try: + containers = compose_service.containers_for_stack(stack.id) + status = compose_service.compute_status(stack.id) + except DockerError: + containers = [] + status = "unknown" + return { + "id": stack.id, + "name": stack.name, + "description": stack.description, + "status": status, + "service_count": len(containers), + "running_count": sum(1 for c in containers if c.state == "running"), + "created_at": stack.created_at, + "updated_at": stack.updated_at, + } + + +# --------------------------------------------------------------------------- # +# CRUD +# --------------------------------------------------------------------------- # + + +@router.get("") +def list_stacks( + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + sync_discovered_stacks(session) + stacks = session.exec(select(Stack)).all() + return [_stack_summary(s) for s in stacks] + + +@router.post("", status_code=201) +def create_stack( + body: StackCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + stack_id = compose_service.slugify(body.name) + if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): + raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") + compose_service.write_compose(stack_id, body.yaml or "services:\n") + if body.env: + compose_service.write_env(stack_id, body.env) + stack = Stack(id=stack_id, name=body.name, description=body.description) + session.add(stack) + session.commit() + session.refresh(stack) + audit_service.record( + session, user=user.username, action="stack.create", target=stack_id, + ip=_client_ip(request), + ) + return _stack_summary(stack) + + +@router.get("/{stack_id}") +def get_stack( + stack_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + stack = _get_stack_or_404(session, stack_id) + try: + containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)] + status = compose_service.compute_status(stack_id) + except DockerError as exc: + containers = [] + status = "unknown" + return { + "id": stack.id, + "name": stack.name, + "description": stack.description, + "status": status, + "yaml": compose_service.read_compose(stack_id), + "env": compose_service.read_env(stack_id), + "containers": containers, + "created_at": stack.created_at, + "updated_at": stack.updated_at, + } + + +@router.put("/{stack_id}") +def update_stack( + stack_id: str, + body: StackUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + stack = _get_stack_or_404(session, stack_id) + if body.yaml is not None: + compose_service.write_compose(stack_id, body.yaml) + if body.env is not None: + compose_service.write_env(stack_id, body.env) + if body.name is not None: + stack.name = body.name + if body.description is not None: + stack.description = body.description + stack.updated_at = compose_service.now() + session.add(stack) + session.commit() + session.refresh(stack) + audit_service.record( + session, user=user.username, action="stack.update", target=stack_id, + ip=_client_ip(request), + ) + return _stack_summary(stack) + + +@router.delete("/{stack_id}") +async def delete_stack( + stack_id: str, + request: Request, + delete_files: bool = Query(True), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + stack = _get_stack_or_404(session, stack_id) + try: + await compose_service.down(stack_id) + except Exception: # noqa: BLE001 - best-effort teardown + pass + if delete_files: + compose_service.delete_stack_files(stack_id) + session.delete(stack) + session.commit() + audit_service.record( + session, user=user.username, action="stack.delete", target=stack_id, + detail=f"delete_files={delete_files}", ip=_client_ip(request), + ) + return {"ok": True} + + +@router.post("/{stack_id}/clone") +def clone_stack( + stack_id: str, + body: StackCloneRequest, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + _get_stack_or_404(session, stack_id) + new_id = compose_service.slugify(body.name) + if session.get(Stack, new_id): + raise HTTPException(status_code=409, detail=f"Stack '{new_id}' already exists") + try: + compose_service.clone_stack_files(stack_id, new_id) + except compose_service.StackFileError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + stack = Stack(id=new_id, name=body.name) + session.add(stack) + session.commit() + session.refresh(stack) + audit_service.record( + session, user=user.username, action="stack.clone", + target=new_id, detail=f"from {stack_id}", ip=_client_ip(request), + ) + return _stack_summary(stack) + + +# --------------------------------------------------------------------------- # +# lifecycle +# --------------------------------------------------------------------------- # + + +async def _lifecycle(action_fn, action_name, stack_id, request, session, user): + _get_stack_or_404(session, stack_id) + result = await action_fn(stack_id) + audit_service.record( + session, user=user.username, action=f"stack.{action_name}", target=stack_id, + detail=f"rc={result.get('returncode')}", ip=_client_ip(request), + ) + if result.get("returncode") not in (0, None): + raise HTTPException( + status_code=500, + detail={ + "error": f"compose {action_name} failed", + "detail": result.get("stderr", "").strip()[-2000:], + }, + ) + return result + + +@router.post("/{stack_id}/start") +async def start_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.up, "start", stack_id, request, session, user) + + +@router.post("/{stack_id}/stop") +async def stop_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.stop, "stop", stack_id, request, session, user) + + +@router.post("/{stack_id}/restart") +async def restart_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.restart, "restart", stack_id, request, session, user) + + +@router.post("/{stack_id}/pull") +async def pull_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.pull, "pull", stack_id, request, session, user) + + +@router.post("/{stack_id}/update") +async def update_stack_images(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.update, "update", stack_id, request, session, user) + + +@router.post("/{stack_id}/down") +async def down_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): + return await _lifecycle(compose_service.down, "down", stack_id, request, session, user) + + +# --------------------------------------------------------------------------- # +# logs / export / convert +# --------------------------------------------------------------------------- # + + +@router.get("/{stack_id}/logs") +async def stack_logs( + stack_id: str, + tail: int = Query(200, le=2000), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + _get_stack_or_404(session, stack_id) + result = await compose_service.logs(stack_id, tail=tail) + return {"logs": result.get("stdout", "") + result.get("stderr", "")} + + +@router.get("/{stack_id}/services/{service}/logs") +async def service_logs( + stack_id: str, + service: str, + tail: int = Query(200, le=2000), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + _get_stack_or_404(session, stack_id) + result = await compose_service.logs(stack_id, service=service, tail=tail) + return {"logs": result.get("stdout", "") + result.get("stderr", "")} + + +@router.get("/{stack_id}/export") +def export_stack( + stack_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +): + import io + import tarfile + import tempfile + + stack = _get_stack_or_404(session, stack_id) + directory = compose_service.stack_dir(stack_id) + if not os.path.isdir(directory): + raise HTTPException(status_code=404, detail="Stack directory missing") + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") + with tarfile.open(tmp.name, "w:gz") as tar: + tar.add(directory, arcname=stack_id) + date = compose_service.now().strftime("%Y%m%d") + return FileResponse( + tmp.name, + media_type="application/gzip", + filename=f"stack-{stack_id}-{date}.tar.gz", + ) + + +@router.post("/convert", response_model=ConvertResponse) +def convert( + body: ConvertRequest, + _user: User = Depends(get_current_user), +) -> ConvertResponse: + try: + return ConvertResponse(yaml=convert_docker_run(body.command)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/system.py b/backend/routers/system.py new file mode 100644 index 0000000..13984cc --- /dev/null +++ b/backend/routers/system.py @@ -0,0 +1,92 @@ +"""Host / Docker system information.""" +from __future__ import annotations + +import os +import shutil + +from fastapi import APIRouter, Depends + +from auth import get_current_user +from config import settings +from docker_client import DockerError, get_client, safe_call +from models.user import User + +router = APIRouter(prefix="/api/system", tags=["system"]) + + +def _read_proc(path: str) -> str: + full = os.path.join(settings.HOST_PROC_PATH, path) + if not os.path.isfile(full): + full = os.path.join("/proc", path) + try: + with open(full, "r", encoding="utf-8") as fh: + return fh.read() + except OSError: + return "" + + +def _mem_info() -> dict: + info = {} + for line in _read_proc("meminfo").splitlines(): + parts = line.split(":") + if len(parts) == 2: + key = parts[0].strip() + val = parts[1].strip().split()[0] + try: + info[key] = int(val) * 1024 # kB -> bytes + except ValueError: + pass + total = info.get("MemTotal", 0) + available = info.get("MemAvailable", info.get("MemFree", 0)) + return {"total": total, "available": available, "used": max(total - available, 0)} + + +def _uptime() -> float: + raw = _read_proc("uptime") + try: + return float(raw.split()[0]) + except (IndexError, ValueError): + return 0.0 + + +def _cpu_count() -> int: + return os.cpu_count() or 0 + + +def _disk_usage() -> dict: + try: + usage = shutil.disk_usage(settings.DATA_DIR) + return {"total": usage.total, "used": usage.used, "free": usage.free} + except OSError: + return {"total": 0, "used": 0, "free": 0} + + +@router.get("/info") +def system_info(_user: User = Depends(get_current_user)) -> dict: + docker_version = "" + host_os = "" + containers_running = 0 + containers_total = 0 + try: + client = get_client() + version = safe_call(client.version) + docker_version = version.get("Version", "") + info = safe_call(client.info) + host_os = info.get("OperatingSystem", "") + containers_running = info.get("ContainersRunning", 0) + containers_total = info.get("Containers", 0) + except DockerError as exc: + docker_version = f"unavailable ({exc.error})" + + return { + "docker_version": docker_version, + "host_os": host_os, + "hostname": os.uname().nodename, + "cpu_cores": _cpu_count(), + "ram": _mem_info(), + "disk": _disk_usage(), + "uptime_seconds": _uptime(), + "containers_running": containers_running, + "containers_total": containers_total, + "gpus": [], # populated in Phase 2 + } diff --git a/backend/routers/ws.py b/backend/routers/ws.py new file mode 100644 index 0000000..3cc6aaf --- /dev/null +++ b/backend/routers/ws.py @@ -0,0 +1,130 @@ +"""WebSocket endpoints for real-time log streaming and Docker events.""" +from __future__ import annotations + +import asyncio +import json + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect +from jose import JWTError + +from auth import decode_token +from services import compose_service + +router = APIRouter(tags=["ws"]) + + +async def _authorize(websocket: WebSocket, token: str | None) -> bool: + """Validate the JWT supplied as a query param. Closes socket on failure.""" + if not token: + await websocket.close(code=4401) + return False + try: + decode_token(token, "access") + except (JWTError, Exception): # noqa: BLE001 + await websocket.close(code=4401) + return False + return True + + +async def _stream_logs(websocket: WebSocket, stack_id: str, service: str | None): + """Stream `docker compose logs -f` output to the client.""" + args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"] + if service: + args.append(service) + try: + async for line in compose_service.stream_compose(stack_id, args): + await websocket.send_text( + json.dumps( + { + "type": "log", + "stack_id": stack_id, + "service": service, + "line": line, + } + ) + ) + except WebSocketDisconnect: + raise + except Exception as exc: # noqa: BLE001 + await websocket.send_text( + json.dumps({"type": "error", "detail": str(exc)}) + ) + + +@router.websocket("/ws/logs/{stack_id}") +async def ws_stack_logs( + websocket: WebSocket, + stack_id: str, + token: str | None = Query(default=None), +): + await websocket.accept() + if not await _authorize(websocket, token): + return + try: + await _stream_logs(websocket, stack_id, None) + except WebSocketDisconnect: + pass + + +@router.websocket("/ws/logs/{stack_id}/{service}") +async def ws_service_logs( + websocket: WebSocket, + stack_id: str, + service: str, + token: str | None = Query(default=None), +): + await websocket.accept() + if not await _authorize(websocket, token): + return + try: + await _stream_logs(websocket, stack_id, service) + except WebSocketDisconnect: + pass + + +@router.websocket("/ws/events") +async def ws_events( + websocket: WebSocket, + token: str | None = Query(default=None), +): + """Stream global Docker events (decoded subset).""" + await websocket.accept() + if not await _authorize(websocket, token): + return + from docker_client import get_client + + loop = asyncio.get_event_loop() + queue: asyncio.Queue = asyncio.Queue() + stop = asyncio.Event() + + def reader(): + try: + client = get_client() + for event in client.events(decode=True): + if stop.is_set(): + break + loop.call_soon_threadsafe(queue.put_nowait, event) + except Exception: # noqa: BLE001 + pass + + task = loop.run_in_executor(None, reader) + try: + while True: + event = await queue.get() + actor = event.get("Actor", {}) or {} + attrs = actor.get("Attributes", {}) or {} + await websocket.send_text( + json.dumps( + { + "type": "event", + "action": event.get("Action"), + "container": attrs.get("name"), + "stack": attrs.get("com.docker.compose.project"), + } + ) + ) + except WebSocketDisconnect: + pass + finally: + stop.set() + task.cancel() diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/audit_service.py b/backend/services/audit_service.py new file mode 100644 index 0000000..b88d8e9 --- /dev/null +++ b/backend/services/audit_service.py @@ -0,0 +1,22 @@ +"""Audit log helper.""" +from __future__ import annotations + +from typing import Optional + +from sqlmodel import Session + +from models.audit import AuditLog + + +def record( + session: Session, + *, + user: str, + action: str, + target: str, + detail: Optional[str] = None, + ip: Optional[str] = None, +) -> None: + entry = AuditLog(user=user, action=action, target=target, detail=detail, ip=ip) + session.add(entry) + session.commit() diff --git a/backend/services/compose_service.py b/backend/services/compose_service.py new file mode 100644 index 0000000..32d9dc6 --- /dev/null +++ b/backend/services/compose_service.py @@ -0,0 +1,366 @@ +"""File-based stack storage and Docker Compose lifecycle. + +The compose YAML on disk is always the source of truth. The database only +stores metadata (name, description, timestamps). +""" +from __future__ import annotations + +import asyncio +import os +import re +import shutil +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from config import settings +from docker_client import DockerError, get_client, safe_call + +COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml") +DEFAULT_COMPOSE_NAME = "compose.yaml" +COMPOSE_LABEL = "com.docker.compose.project" +SERVICE_LABEL = "com.docker.compose.service" + + +class StackFileError(Exception): + pass + + +# --------------------------------------------------------------------------- # +# Slug / paths +# --------------------------------------------------------------------------- # + + +def slugify(name: str) -> str: + slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()) + slug = re.sub(r"-{2,}", "-", slug).strip("-_") + return slug or "stack" + + +def stacks_root(override: Optional[str] = None) -> str: + return override or settings.STACKS_DIR + + +def stack_dir(stack_id: str, override: Optional[str] = None) -> str: + return os.path.join(stacks_root(override), stack_id) + + +def find_compose_file(directory: str) -> Optional[str]: + for name in COMPOSE_FILENAMES: + candidate = os.path.join(directory, name) + if os.path.isfile(candidate): + return candidate + return None + + +def compose_path(stack_id: str, override: Optional[str] = None) -> str: + directory = stack_dir(stack_id, override) + return find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME) + + +def env_path(stack_id: str, override: Optional[str] = None) -> str: + return os.path.join(stack_dir(stack_id, override), ".env") + + +# --------------------------------------------------------------------------- # +# Read / write files +# --------------------------------------------------------------------------- # + + +def read_compose(stack_id: str, override: Optional[str] = None) -> str: + path = compose_path(stack_id, override) + if not os.path.isfile(path): + return "" + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + + +def read_env(stack_id: str, override: Optional[str] = None) -> str: + path = env_path(stack_id, override) + if not os.path.isfile(path): + return "" + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + + +def write_compose(stack_id: str, content: str, override: Optional[str] = None) -> None: + directory = stack_dir(stack_id, override) + os.makedirs(directory, exist_ok=True) + path = compose_path(stack_id, override) + # Non-destructive: back up existing file first. + if os.path.isfile(path): + shutil.copy2(path, path + ".bak") + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, path) + + +def write_env(stack_id: str, content: str, override: Optional[str] = None) -> None: + directory = stack_dir(stack_id, override) + os.makedirs(directory, exist_ok=True) + path = env_path(stack_id, override) + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + + +def delete_stack_files(stack_id: str, override: Optional[str] = None) -> None: + directory = stack_dir(stack_id, override) + if os.path.isdir(directory): + shutil.rmtree(directory) + + +def clone_stack_files(src_id: str, dst_id: str, override: Optional[str] = None) -> None: + src = stack_dir(src_id, override) + dst = stack_dir(dst_id, override) + if os.path.isdir(dst): + raise StackFileError(f"Target stack '{dst_id}' already exists") + shutil.copytree(src, dst) + + +def discover_stacks(override: Optional[str] = None) -> list[str]: + """Return ids of all directories under STACKS_DIR that contain a compose file.""" + root = stacks_root(override) + if not os.path.isdir(root): + return [] + found = [] + for entry in sorted(os.listdir(root)): + directory = os.path.join(root, entry) + if os.path.isdir(directory) and find_compose_file(directory): + found.append(entry) + return found + + +# --------------------------------------------------------------------------- # +# Live status from Docker +# --------------------------------------------------------------------------- # + + +@dataclass +class ContainerInfo: + id: str + name: str + service: str + image: str + state: str # running, exited, ... + status: str # human string + health: Optional[str] = None + ports: list[dict] = field(default_factory=list) + created: Optional[str] = None + + +def _parse_ports(attrs: dict) -> list[dict]: + ports = [] + bindings = (attrs.get("NetworkSettings") or {}).get("Ports") or {} + for container_port, host in (bindings or {}).items(): + if host: + for binding in host: + ports.append( + { + "container": container_port, + "host_ip": binding.get("HostIp"), + "host_port": binding.get("HostPort"), + } + ) + else: + ports.append({"container": container_port, "host_port": None}) + return ports + + +def containers_for_stack(stack_id: str) -> list[ContainerInfo]: + client = get_client() + raw = safe_call( + client.containers.list, + all=True, + filters={"label": f"{COMPOSE_LABEL}={stack_id}"}, + ) + result = [] + for c in raw: + attrs = c.attrs + state = attrs.get("State", {}) or {} + health = (state.get("Health") or {}).get("Status") + result.append( + ContainerInfo( + id=c.id, + name=c.name, + service=c.labels.get(SERVICE_LABEL, c.name), + image=(c.image.tags[0] if c.image and c.image.tags else attrs.get("Config", {}).get("Image", "")), + state=state.get("Status", c.status), + status=attrs.get("State", {}).get("Status", c.status), + health=health, + ports=_parse_ports(attrs), + created=attrs.get("Created"), + ) + ) + return result + + +# in-memory set of stacks currently performing a pull/up +_BUSY: set[str] = set() + + +def mark_busy(stack_id: str) -> None: + _BUSY.add(stack_id) + + +def clear_busy(stack_id: str) -> None: + _BUSY.discard(stack_id) + + +def compute_status(stack_id: str) -> str: + if stack_id in _BUSY: + return "updating" + try: + containers = containers_for_stack(stack_id) + except DockerError: + return "unknown" + if not containers: + return "stopped" + states = [c.state for c in containers] + if any(s in ("dead",) for s in states): + return "error" + if any( + c.state == "exited" and _nonzero_exit(c) for c in containers + ): + return "error" + running = [s for s in states if s == "running"] + if len(running) == len(states): + return "running" + if running: + return "partial" + return "stopped" + + +def _nonzero_exit(c: ContainerInfo) -> bool: + # We only have the textual state here; treat plain "exited" as stopped, not + # an error unless health says otherwise. Detailed exit codes handled in detail view. + return False + + +# --------------------------------------------------------------------------- # +# Compose CLI lifecycle (async subprocess) +# --------------------------------------------------------------------------- # + + +def _compose_base_cmd(stack_id: str, override: Optional[str] = None) -> list[str]: + directory = stack_dir(stack_id, override) + compose_file = find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME) + return [ + "docker", + "compose", + "-p", + stack_id, + "--project-directory", + directory, + "-f", + compose_file, + ] + + +async def run_compose( + stack_id: str, + args: list[str], + override: Optional[str] = None, + timeout: float = 600.0, +) -> dict: + """Run a `docker compose` subcommand. Returns {returncode, stdout, stderr}.""" + cmd = _compose_base_cmd(stack_id, override) + args + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError as exc: + proc.kill() + raise StackFileError(f"compose command timed out: {' '.join(args)}") from exc + return { + "returncode": proc.returncode, + "stdout": stdout_b.decode("utf-8", "replace"), + "stderr": stderr_b.decode("utf-8", "replace"), + "command": " ".join(args), + } + + +async def stream_compose( + stack_id: str, args: list[str], override: Optional[str] = None +): + """Yield lines from a `docker compose` subcommand as they are produced.""" + cmd = _compose_base_cmd(stack_id, override) + args + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + assert proc.stdout is not None + async for raw in proc.stdout: + yield raw.decode("utf-8", "replace").rstrip("\n") + await proc.wait() + + +# Convenience lifecycle wrappers ------------------------------------------------ + + +async def up(stack_id: str, override: Optional[str] = None) -> dict: + mark_busy(stack_id) + try: + return await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override) + finally: + clear_busy(stack_id) + + +async def down(stack_id: str, override: Optional[str] = None) -> dict: + return await run_compose(stack_id, ["down"], override) + + +async def start(stack_id: str, override: Optional[str] = None) -> dict: + return await run_compose(stack_id, ["start"], override) + + +async def stop(stack_id: str, override: Optional[str] = None) -> dict: + return await run_compose(stack_id, ["stop"], override) + + +async def restart(stack_id: str, override: Optional[str] = None) -> dict: + return await run_compose(stack_id, ["restart"], override) + + +async def pull(stack_id: str, override: Optional[str] = None) -> dict: + mark_busy(stack_id) + try: + return await run_compose(stack_id, ["pull"], override) + finally: + clear_busy(stack_id) + + +async def update(stack_id: str, override: Optional[str] = None) -> dict: + """Pull then up -d.""" + mark_busy(stack_id) + try: + pull_res = await run_compose(stack_id, ["pull"], override) + up_res = await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override) + return { + "returncode": up_res["returncode"], + "stdout": pull_res["stdout"] + "\n" + up_res["stdout"], + "stderr": pull_res["stderr"] + "\n" + up_res["stderr"], + "command": "pull + up -d", + } + finally: + clear_busy(stack_id) + + +async def logs( + stack_id: str, + service: Optional[str] = None, + tail: int = 200, + override: Optional[str] = None, +) -> dict: + args = ["logs", "--no-color", "--tail", str(tail), "--timestamps"] + if service: + args.append(service) + return await run_compose(stack_id, args, override, timeout=60.0) + + +def now() -> datetime: + return datetime.now(timezone.utc) diff --git a/backend/services/convert_service.py b/backend/services/convert_service.py new file mode 100644 index 0000000..f4f3ba1 --- /dev/null +++ b/backend/services/convert_service.py @@ -0,0 +1,135 @@ +"""Convert a `docker run ...` command string into a Compose YAML fragment.""" +from __future__ import annotations + +import shlex + +import yaml + + +def _service_name_from_image(image: str) -> str: + name = image.split("/")[-1].split(":")[0] + return name or "app" + + +def convert_docker_run(command: str) -> str: + tokens = shlex.split(command) + # Drop leading "docker run" / "docker container run". + while tokens and tokens[0] in ("docker", "run", "container", "sudo"): + tokens.pop(0) + + service: dict = {} + name = None + image = None + ports: list[str] = [] + volumes: list[str] = [] + environment: list[str] = [] + env_file: list[str] = [] + devices: list[str] = [] + cap_add: list[str] = [] + labels: list[str] = [] + networks: list[str] = [] + command_args: list[str] = [] + + i = 0 + n = len(tokens) + + def take_value(idx: int, tok: str): + if "=" in tok and tok.startswith("--") and not tok.endswith("="): + return tok.split("=", 1)[1], idx + 1 + return tokens[idx + 1], idx + 2 + + while i < n: + tok = tokens[i] + if image is not None: + # Everything after the image is the container command. + command_args = tokens[i:] + break + if not tok.startswith("-"): + image = tok + i += 1 + continue + + flag = tok.split("=", 1)[0] + if flag in ("-d", "--detach", "-i", "--interactive", "-t", "--tty", "--rm", "--init"): + i += 1 + elif flag in ("--name",): + name, i = take_value(i, tok) + elif flag in ("-p", "--publish"): + val, i = take_value(i, tok) + ports.append(val) + elif flag in ("-v", "--volume", "--mount"): + val, i = take_value(i, tok) + volumes.append(val) + elif flag in ("-e", "--env"): + val, i = take_value(i, tok) + environment.append(val) + elif flag in ("--env-file",): + val, i = take_value(i, tok) + env_file.append(val) + elif flag in ("--device",): + val, i = take_value(i, tok) + devices.append(val) + elif flag in ("--cap-add",): + val, i = take_value(i, tok) + cap_add.append(val) + elif flag in ("-l", "--label"): + val, i = take_value(i, tok) + labels.append(val) + elif flag in ("--network", "--net"): + val, i = take_value(i, tok) + networks.append(val) + elif flag in ("--restart",): + val, i = take_value(i, tok) + service["restart"] = val + elif flag in ("--privileged",): + service["privileged"] = True + i += 1 + elif flag in ("--hostname", "-h"): + val, i = take_value(i, tok) + service["hostname"] = val + elif flag in ("-u", "--user"): + val, i = take_value(i, tok) + service["user"] = val + elif flag in ("-w", "--workdir"): + val, i = take_value(i, tok) + service["working_dir"] = val + else: + # Unknown flag — try to consume a value if it looks like it takes one. + if "=" in tok: + i += 1 + elif i + 1 < n and not tokens[i + 1].startswith("-"): + i += 2 + else: + i += 1 + + if not image: + raise ValueError("Could not find an image in the docker run command") + + service["image"] = image + if "restart" not in service: + service["restart"] = "unless-stopped" + if ports: + service["ports"] = ports + if volumes: + service["volumes"] = volumes + if environment: + service["environment"] = environment + if env_file: + service["env_file"] = env_file + if devices: + service["devices"] = devices + if cap_add: + service["cap_add"] = cap_add + if labels: + service["labels"] = labels + if networks: + service["networks"] = networks + if command_args: + service["command"] = command_args + + svc_name = name or _service_name_from_image(image) + if name: + service["container_name"] = name + + doc = {"services": {svc_name: service}} + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d779092 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + backend: + build: ./backend + image: stackpilot/backend:latest + restart: unless-stopped + environment: + - SECRET_KEY=${SECRET_KEY} + - STACKS_DIR=/opt/stacks + - DATA_DIR=/data + - HOST_PROC_PATH=/host_proc + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5009} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/data + - ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks + - /proc:/host_proc:ro + expose: + - "5008" + # Uncomment to expose the API directly (normally proxied by the frontend): + # ports: + # - "5008:5008" + + frontend: + build: ./frontend + image: stackpilot/frontend:latest + restart: unless-stopped + depends_on: + - backend + ports: + - "5009:80" diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3be2309 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..05b1a88 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3851cd5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + StackPilot + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..354c26e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy + location /api/ { + proxy_pass http://backend:5008; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 600s; + } + + # WebSocket proxy + location /ws/ { + proxy_pass http://backend:5008; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + } + + # Basic security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cc1aa41 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "stackpilot-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.6.0", + "@tanstack/react-query": "^5.62.7", + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^20.17.10", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b58d85d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom"; +import { Layout } from "@/components/layout/Layout"; +import { Login } from "@/pages/Login"; +import { Dashboard } from "@/pages/Dashboard"; +import { Stacks } from "@/pages/Stacks"; +import { StackDetail } from "@/pages/StackDetail"; +import { StackEditor } from "@/pages/StackEditor"; +import { Networks, Images, Templates, Settings } from "@/pages/Placeholder"; +import { useAuthStore } from "@/store/auth"; +import { useThemeStore } from "@/store/theme"; + +function RequireAuth() { + const token = useAuthStore((s) => s.accessToken); + return token ? : ; +} + +export default function App() { + const applyTheme = useThemeStore((s) => s.apply); + const fetchMe = useAuthStore((s) => s.fetchMe); + const token = useAuthStore((s) => s.accessToken); + + useEffect(() => { + applyTheme(); + }, [applyTheme]); + + useEffect(() => { + if (token) fetchMe().catch(() => {}); + }, [token, fetchMe]); + + return ( + + + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..2ba1e63 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,50 @@ +import axios, { AxiosError } from "axios"; +import { useAuthStore } from "@/store/auth"; + +const api = axios.create({ baseURL: "/" }); + +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +let refreshing: Promise | null = null; + +api.interceptors.response.use( + (res) => res, + async (error: AxiosError) => { + const original = error.config as any; + if (error.response?.status === 401 && original && !original._retry) { + original._retry = true; + if (!refreshing) { + refreshing = useAuthStore + .getState() + .refresh() + .finally(() => { + refreshing = null; + }); + } + const newToken = await refreshing; + if (newToken) { + original.headers.Authorization = `Bearer ${newToken}`; + return api(original); + } + useAuthStore.getState().logout(); + } + return Promise.reject(error); + } +); + +export function apiErrorMessage(err: unknown): string { + const e = err as AxiosError; + const detail = e?.response?.data?.detail; + if (typeof detail === "string") return detail; + if (detail?.detail) return `${detail.error}: ${detail.detail}`; + if (detail?.error) return detail.error; + return e?.message || "Unexpected error"; +} + +export default api; diff --git a/frontend/src/api/stacks.ts b/frontend/src/api/stacks.ts new file mode 100644 index 0000000..14cb610 --- /dev/null +++ b/frontend/src/api/stacks.ts @@ -0,0 +1,28 @@ +import api from "./client"; +import type { StackDetail, StackSummary } from "@/types"; + +export const stacksApi = { + list: () => api.get("/api/stacks").then((r) => r.data), + get: (id: string) => + api.get(`/api/stacks/${id}`).then((r) => r.data), + create: (body: { name: string; description?: string; yaml?: string; env?: string }) => + api.post("/api/stacks", body).then((r) => r.data), + update: (id: string, body: { name?: string; description?: string; yaml?: string; env?: string }) => + api.put(`/api/stacks/${id}`, body).then((r) => r.data), + remove: (id: string, deleteFiles = true) => + api.delete(`/api/stacks/${id}?delete_files=${deleteFiles}`).then((r) => r.data), + clone: (id: string, name: string) => + api.post(`/api/stacks/${id}/clone`, { name }).then((r) => r.data), + + start: (id: string) => api.post(`/api/stacks/${id}/start`).then((r) => r.data), + stop: (id: string) => api.post(`/api/stacks/${id}/stop`).then((r) => r.data), + restart: (id: string) => api.post(`/api/stacks/${id}/restart`).then((r) => r.data), + pull: (id: string) => api.post(`/api/stacks/${id}/pull`).then((r) => r.data), + update_images: (id: string) => api.post(`/api/stacks/${id}/update`).then((r) => r.data), + down: (id: string) => api.post(`/api/stacks/${id}/down`).then((r) => r.data), + + logs: (id: string, tail = 200) => + api.get<{ logs: string }>(`/api/stacks/${id}/logs?tail=${tail}`).then((r) => r.data), + convert: (command: string) => + api.post<{ yaml: string }>("/api/stacks/convert", { command }).then((r) => r.data), +}; diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..9509245 --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,8 @@ +import api from "./client"; +import type { AuditEntry, SystemInfo } from "@/types"; + +export const systemApi = { + info: () => api.get("/api/system/info").then((r) => r.data), + audit: (limit = 10) => + api.get(`/api/audit?limit=${limit}`).then((r) => r.data), +}; diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..a63048a --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from "react-router-dom"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; + +export function Layout() { + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..fe4a234 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,92 @@ +import { NavLink } from "react-router-dom"; +import { + LayoutDashboard, + Boxes, + Network, + Image, + LayoutTemplate, + Settings, + Moon, + Sun, + LogOut, + Ship, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/store/auth"; +import { useThemeStore } from "@/store/theme"; + +const nav = [ + { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, + { to: "/stacks", label: "Stacks", icon: Boxes }, + { to: "/networks", label: "Networks", icon: Network }, + { to: "/images", label: "Images", icon: Image }, + { to: "/templates", label: "Templates", icon: LayoutTemplate }, + { to: "/settings", label: "Settings", icon: Settings }, +]; + +export function Sidebar() { + const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); + const { theme, toggle } = useThemeStore(); + + return ( + + ); +} diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..d6c717f --- /dev/null +++ b/frontend/src/components/layout/Topbar.tsx @@ -0,0 +1,22 @@ +import { useLocation } from "react-router-dom"; + +const titles: Record = { + "": "Dashboard", + stacks: "Stacks", + networks: "Networks", + images: "Images", + templates: "Templates", + settings: "Settings", +}; + +export function Topbar() { + const { pathname } = useLocation(); + const segment = pathname.split("/")[1] ?? ""; + const title = titles[segment] ?? "StackPilot"; + + return ( +
+

{title}

+
+ ); +} diff --git a/frontend/src/components/stacks/LogViewer.tsx b/frontend/src/components/stacks/LogViewer.tsx new file mode 100644 index 0000000..966056a --- /dev/null +++ b/frontend/src/components/stacks/LogViewer.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from "react"; +import { ArrowDownToLine, Pause, Play } from "lucide-react"; +import { Button } from "@/components/ui"; +import { useAuthStore } from "@/store/auth"; + +const MAX_LINES = 2000; + +const serviceColors = [ + "text-sky-400", + "text-emerald-400", + "text-amber-400", + "text-fuchsia-400", + "text-rose-400", + "text-lime-400", +]; + +function colorFor(service: string | null): string { + if (!service) return "text-slate-300"; + let h = 0; + for (const c of service) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return serviceColors[h % serviceColors.length]; +} + +export function LogViewer({ stackId }: { stackId: string }) { + const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]); + const [autoScroll, setAutoScroll] = useState(true); + const [connected, setConnected] = useState(false); + const containerRef = useRef(null); + const token = useAuthStore((s) => s.accessToken); + + useEffect(() => { + if (!token) return; + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`; + const ws = new WebSocket(url); + ws.onopen = () => setConnected(true); + ws.onclose = () => setConnected(false); + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "log") { + setLines((prev) => { + const next = [...prev, { service: msg.service, line: msg.line }]; + return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next; + }); + } + } catch { + /* ignore */ + } + }; + return () => ws.close(); + }, [stackId, token]); + + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [lines, autoScroll]); + + const download = () => { + const blob = new Blob([lines.map((l) => l.line).join("\n")], { + type: "text/plain", + }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${stackId}-logs.txt`; + a.click(); + }; + + return ( +
+
+ + {connected ? ( + ● live + ) : ( + ○ disconnected + )} + {lines.length} lines + +
+ + +
+
+
+ {lines.length === 0 && ( +
Waiting for log output…
+ )} + {lines.map((l, i) => ( +
+ {l.service && ( + {l.service} + )} + {l.line} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/stacks/StackCard.tsx b/frontend/src/components/stacks/StackCard.tsx new file mode 100644 index 0000000..517e675 --- /dev/null +++ b/frontend/src/components/stacks/StackCard.tsx @@ -0,0 +1,96 @@ +import { Link } from "react-router-dom"; +import { Play, Square, RotateCw, Pencil } from "lucide-react"; +import { Card, StatusDot, Badge } from "@/components/ui"; +import { relativeTime } from "@/lib/utils"; +import type { StackSummary } from "@/types"; + +interface Props { + stack: StackSummary; + onStart: (id: string) => void; + onStop: (id: string) => void; + onRestart: (id: string) => void; + busy?: boolean; + isAdmin?: boolean; +} + +export function StackCard({ + stack, + onStart, + onStop, + onRestart, + busy, + isAdmin, +}: Props) { + return ( + +
+ +
+ + + {stack.name} + +
+ {stack.description && ( +

+ {stack.description} +

+ )} + + {stack.status} +
+ +
+ + {stack.running_count}/{stack.service_count} services + + · + updated {relativeTime(stack.updated_at)} +
+ + {isAdmin && ( +
+ onStart(stack.id)} disabled={busy}> + + + onStop(stack.id)} disabled={busy}> + + + onRestart(stack.id)} disabled={busy}> + + + + + +
+ )} +
+ ); +} + +function IconBtn({ + children, + title, + onClick, + disabled, +}: { + children: React.ReactNode; + title: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx new file mode 100644 index 0000000..2013891 --- /dev/null +++ b/frontend/src/components/ui/index.tsx @@ -0,0 +1,138 @@ +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react"; +import type { StackStatus } from "@/types"; + +export function Card({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +type Variant = "primary" | "ghost" | "danger" | "outline"; + +const variantClasses: Record = { + primary: + "bg-accent text-white hover:bg-sky-600 dark:bg-accent-dark dark:text-slate-900 dark:hover:bg-sky-300", + ghost: + "bg-transparent text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700", + danger: "bg-red-600 text-white hover:bg-red-700", + outline: + "border border-slate-300 bg-transparent text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700", +}; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + loading?: boolean; +} + +export function Button({ + variant = "primary", + loading, + className, + children, + disabled, + ...props +}: ButtonProps) { + return ( + + ); +} + +export function Input({ + className, + ...props +}: InputHTMLAttributes) { + return ( + + ); +} + +const statusColor: Record = { + running: "bg-green-500", + partial: "bg-yellow-500", + stopped: "bg-slate-400", + error: "bg-red-500", + updating: "bg-sky-500 animate-pulse", + unknown: "bg-slate-300", +}; + +export function StatusDot({ status }: { status: StackStatus }) { + return ( + + ); +} + +export function Badge({ + status, + children, +}: { + status?: StackStatus; + children: ReactNode; +}) { + const tone = status + ? { + running: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", + partial: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", + stopped: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300", + error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", + updating: "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300", + unknown: "bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400", + }[status] + : "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300"; + return ( + + {children} + + ); +} + +export function Spinner() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useStackActions.ts b/frontend/src/hooks/useStackActions.ts new file mode 100644 index 0000000..0e6b377 --- /dev/null +++ b/frontend/src/hooks/useStackActions.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { stacksApi } from "@/api/stacks"; +import { apiErrorMessage } from "@/api/client"; + +export function useStackActions() { + const qc = useQueryClient(); + const [busyId, setBusyId] = useState(null); + + const run = async ( + id: string, + label: string, + fn: (id: string) => Promise + ) => { + setBusyId(id); + const t = toast.loading(`${label} ${id}…`); + try { + await fn(id); + toast.success(`${label} ${id} ✓`, { id: t }); + qc.invalidateQueries({ queryKey: ["stacks"] }); + qc.invalidateQueries({ queryKey: ["stack", id] }); + } catch (err) { + toast.error(apiErrorMessage(err), { id: t }); + } finally { + setBusyId(null); + } + }; + + return { + busyId, + start: (id: string) => run(id, "Starting", stacksApi.start), + stop: (id: string) => run(id, "Stopping", stacksApi.stop), + restart: (id: string) => run(id, "Restarting", stacksApi.restart), + pull: (id: string) => run(id, "Pulling", stacksApi.pull), + updateImages: (id: string) => run(id, "Updating", stacksApi.update_images), + down: (id: string) => run(id, "Tearing down", stacksApi.down), + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5826163 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,28 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light dark; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.4); + border-radius: 4px; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..747f847 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,35 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +export function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d) return `${d}d ${h}h`; + if (h) return `${h}h ${m}m`; + return `${m}m`; +} + +export function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + const diff = Date.now() - then; + const s = Math.floor(diff / 1000); + if (s < 60) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2f30f97 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..8c50673 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query"; +import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react"; +import { Card, Spinner } from "@/components/ui"; +import { StackCard } from "@/components/stacks/StackCard"; +import { stacksApi } from "@/api/stacks"; +import { systemApi } from "@/api/system"; +import { formatBytes, formatUptime, relativeTime } from "@/lib/utils"; +import { useAuthStore } from "@/store/auth"; +import { useStackActions } from "@/hooks/useStackActions"; + +export function Dashboard() { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const { busyId, start, stop, restart } = useStackActions(); + + const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); + const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 }); + const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); + + return ( +
+ {/* Resource bar */} +
+ } label="CPU cores" value={info.data?.cpu_cores ?? "—"} /> + } + label="Memory" + value={ + info.data + ? `${formatBytes(info.data.ram.used)} / ${formatBytes(info.data.ram.total)}` + : "—" + } + /> + } + label="Containers" + value={ + info.data + ? `${info.data.containers_running} / ${info.data.containers_total}` + : "—" + } + /> + } + label="Docker" + value={info.data?.docker_version ?? "—"} + /> +
+ + {/* Stacks grid */} +
+

+ Stacks +

+ {stacks.isLoading ? ( + + ) : stacks.data && stacks.data.length > 0 ? ( +
+ {stacks.data.map((s) => ( + + ))} +
+ ) : ( + +

+ No stacks yet. Create one from the Stacks page. +

+
+ )} +
+ + {/* Recent activity */} +
+

+ Recent activity +

+ + {audit.data && audit.data.length > 0 ? ( +
    + {audit.data.map((a) => ( +
  • + + {a.user}{" "} + {a.action}{" "} + + {a.target} + + + + {relativeTime(a.timestamp)} + +
  • + ))} +
+ ) : ( +

No activity yet.

+ )} +
+
+
+ ); +} + +function Stat({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +}) { + return ( + +
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..c45a4a5 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; +import { Ship } from "lucide-react"; +import { Button, Card, Input } from "@/components/ui"; +import { useAuthStore } from "@/store/auth"; +import { apiErrorMessage } from "@/api/client"; +import { toast } from "sonner"; + +export function Login() { + const navigate = useNavigate(); + const { login, setup, accessToken } = useAuthStore(); + const [needsSetup, setNeedsSetup] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (accessToken) navigate("/"); + axios + .get("/api/auth/needs-setup") + .then((r) => setNeedsSetup(r.data.needs_setup)) + .catch(() => {}); + }, [accessToken, navigate]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (needsSetup && password !== confirm) { + toast.error("Passwords do not match"); + return; + } + setLoading(true); + try { + if (needsSetup) { + await setup(username, password); + toast.success("Admin account created"); + } else { + await login(username, password); + } + navigate("/"); + } catch (err) { + toast.error(apiErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ +

+ StackPilot +

+

+ {needsSetup ? "Create your admin account" : "Sign in to continue"} +

+
+
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + {needsSetup && ( + setConfirm(e.target.value)} + /> + )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/Placeholder.tsx b/frontend/src/pages/Placeholder.tsx new file mode 100644 index 0000000..8b092ad --- /dev/null +++ b/frontend/src/pages/Placeholder.tsx @@ -0,0 +1,20 @@ +import { Construction } from "lucide-react"; +import { Card } from "@/components/ui"; + +export function Placeholder({ title, phase }: { title: string; phase: string }) { + return ( + + +

{title}

+

+ This section is part of {phase}. The backend foundation is ready — the UI + lands in an upcoming build phase. +

+
+ ); +} + +export const Networks = () => ; +export const Images = () => ; +export const Templates = () => ; +export const Settings = () => ; diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx new file mode 100644 index 0000000..cd73ba3 --- /dev/null +++ b/frontend/src/pages/StackDetail.tsx @@ -0,0 +1,161 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Play, + Square, + RotateCw, + DownloadCloud, + ArrowUpCircle, + Pencil, + Power, +} from "lucide-react"; +import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; +import { LogViewer } from "@/components/stacks/LogViewer"; +import { stacksApi } from "@/api/stacks"; +import { useAuthStore } from "@/store/auth"; +import { useStackActions } from "@/hooks/useStackActions"; + +const TABS = ["Overview", "Logs", "Environment", "Compose"] as const; +type Tab = (typeof TABS)[number]; + +export function StackDetail() { + const { id = "" } = useParams(); + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const [tab, setTab] = useState("Overview"); + const actions = useStackActions(); + + const { data, isLoading } = useQuery({ + queryKey: ["stack", id], + queryFn: () => stacksApi.get(id), + refetchInterval: 5000, + }); + + if (isLoading || !data) return ; + const busy = actions.busyId === id; + + return ( +
+
+
+
+ +

{data.name}

+ {data.status} +
+ {data.description && ( +

{data.description}

+ )} +
+ {isAdmin && ( +
+ + + + + + + + + +
+ )} +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === "Overview" && } + {tab === "Logs" && } + {tab === "Environment" && } + {tab === "Compose" && } +
+
+ ); +} + +function Overview({ data }: { data: ReturnType & any }) { + return ( +
+ {data.containers.length === 0 && ( + +

+ No containers running. Start the stack to see services. +

+
+ )} + {data.containers.map((c: any) => ( + +
+ +
+

{c.service}

+

{c.image}

+
+
+
+ {c.health && {c.health}} + {c.status} + {c.ports.length > 0 && ( + + {c.ports + .filter((p: any) => p.host_port) + .map((p: any) => `${p.host_port}→${p.container}`) + .join(", ")} + + )} +
+
+ ))} +
+ ); +} + +function EnvView({ env }: { env: string }) { + return ( + + {env ? ( +
{env}
+ ) : ( +

No .env file for this stack.

+ )} +
+ ); +} + +function ComposeView({ yaml }: { yaml: string }) { + return ( + +
{yaml}
+
+ ); +} diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx new file mode 100644 index 0000000..cd8da4c --- /dev/null +++ b/frontend/src/pages/StackEditor.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Editor from "@monaco-editor/react"; +import { Rocket, Save, Wand2, FileCode } from "lucide-react"; +import { Button, Card, Input } from "@/components/ui"; +import { stacksApi } from "@/api/stacks"; +import { apiErrorMessage } from "@/api/client"; +import { useThemeStore } from "@/store/theme"; +import { toast } from "sonner"; + +const STARTER = `services: + app: + image: nginx:alpine + restart: unless-stopped + ports: + - "8080:80" +`; + +export function StackEditor() { + const { id } = useParams(); + const isNew = !id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const theme = useThemeStore((s) => s.theme); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [yaml, setYaml] = useState(STARTER); + const [env, setEnv] = useState(""); + const [tab, setTab] = useState<"compose" | "env">("compose"); + const [saving, setSaving] = useState(false); + const [convertOpen, setConvertOpen] = useState(false); + const [runCmd, setRunCmd] = useState(""); + + const existing = useQuery({ + queryKey: ["stack", id], + queryFn: () => stacksApi.get(id!), + enabled: !isNew, + }); + + useEffect(() => { + if (existing.data) { + setName(existing.data.name); + setDescription(existing.data.description ?? ""); + setYaml(existing.data.yaml || STARTER); + setEnv(existing.data.env || ""); + } + }, [existing.data]); + + const save = async (deploy: boolean) => { + if (isNew && !name.trim()) { + toast.error("Stack name is required"); + return; + } + setSaving(true); + try { + let stackId = id; + if (isNew) { + const created = await stacksApi.create({ name, description, yaml, env }); + stackId = created.id; + } else { + await stacksApi.update(id!, { name, description, yaml, env }); + } + qc.invalidateQueries({ queryKey: ["stacks"] }); + qc.invalidateQueries({ queryKey: ["stack", stackId] }); + toast.success("Saved"); + if (deploy && stackId) { + const t = toast.loading("Deploying…"); + await stacksApi.start(stackId); + toast.success("Deployed ✓", { id: t }); + } + navigate(`/stacks/${stackId}`); + } catch (err) { + toast.error(apiErrorMessage(err)); + } finally { + setSaving(false); + } + }; + + const convert = async () => { + try { + const { yaml: converted } = await stacksApi.convert(runCmd); + setYaml(converted); + setConvertOpen(false); + setRunCmd(""); + toast.success("Converted to compose"); + } catch (err) { + toast.error(apiErrorMessage(err)); + } + }; + + return ( +
+
+ setName(e.target.value)} + disabled={!isNew} + /> + setDescription(e.target.value)} + /> + +
+ + {convertOpen && ( + + setRunCmd(e.target.value)} + /> + + + )} + +
+ setTab("compose")}> + compose.yaml + + setTab("env")}> + .env + +
+ +
+ {tab === "compose" ? ( + setYaml(v ?? "")} + options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }} + /> + ) : ( + setEnv(v ?? "")} + options={{ minimap: { enabled: false }, fontSize: 13 }} + /> + )} +
+ +
+ + +
+
+ ); +} + +function TabBtn({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx new file mode 100644 index 0000000..9abdf81 --- /dev/null +++ b/frontend/src/pages/Stacks.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Plus, Search } from "lucide-react"; +import { Button, Input, Spinner, Card } from "@/components/ui"; +import { StackCard } from "@/components/stacks/StackCard"; +import { stacksApi } from "@/api/stacks"; +import { useAuthStore } from "@/store/auth"; +import { useStackActions } from "@/hooks/useStackActions"; + +type SortKey = "name" | "status" | "updated"; + +export function Stacks() { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const { busyId, start, stop, restart } = useStackActions(); + const [q, setQ] = useState(""); + const [sort, setSort] = useState("name"); + + const { data, isLoading } = useQuery({ + queryKey: ["stacks"], + queryFn: stacksApi.list, + refetchInterval: 5000, + }); + + const filtered = useMemo(() => { + let list = (data ?? []).filter( + (s) => + s.name.toLowerCase().includes(q.toLowerCase()) || + s.id.toLowerCase().includes(q.toLowerCase()) + ); + list = [...list].sort((a, b) => { + if (sort === "name") return a.name.localeCompare(b.name); + if (sort === "status") return a.status.localeCompare(b.status); + return b.updated_at.localeCompare(a.updated_at); + }); + return list; + }, [data, q, sort]); + + return ( +
+
+
+ + setQ(e.target.value)} + /> +
+ + {isAdmin && ( + + + + )} +
+ + {isLoading ? ( + + ) : filtered.length > 0 ? ( +
+ {filtered.map((s) => ( + + ))} +
+ ) : ( + +

No stacks match your search.

+
+ )} +
+ ); +} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000..678bb83 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,78 @@ +import axios from "axios"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TokenPair, User } from "@/types"; + +interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: User | null; + setTokens: (t: TokenPair) => void; + login: (username: string, password: string) => Promise; + setup: (username: string, password: string) => Promise; + refresh: () => Promise; + fetchMe: () => Promise; + logout: () => void; +} + +// Raw client without interceptors (avoids refresh loops). +const raw = axios.create({ baseURL: "/" }); + +export const useAuthStore = create()( + persist( + (set, get) => ({ + accessToken: null, + refreshToken: null, + user: null, + + setTokens: (t) => + set({ accessToken: t.access_token, refreshToken: t.refresh_token }), + + login: async (username, password) => { + const { data } = await raw.post("/api/auth/login", { + username, + password, + }); + set({ accessToken: data.access_token, refreshToken: data.refresh_token }); + await get().fetchMe(); + }, + + setup: async (username, password) => { + const { data } = await raw.post("/api/auth/setup", { + username, + password, + role: "admin", + }); + set({ accessToken: data.access_token, refreshToken: data.refresh_token }); + await get().fetchMe(); + }, + + refresh: async () => { + const rt = get().refreshToken; + if (!rt) return null; + try { + const { data } = await raw.post("/api/auth/refresh", { + refresh_token: rt, + }); + set({ accessToken: data.access_token, refreshToken: data.refresh_token }); + return data.access_token; + } catch { + set({ accessToken: null, refreshToken: null, user: null }); + return null; + } + }, + + fetchMe: async () => { + const token = get().accessToken; + if (!token) return; + const { data } = await raw.get("/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + set({ user: data }); + }, + + logout: () => set({ accessToken: null, refreshToken: null, user: null }), + }), + { name: "stackpilot-auth" } + ) +); diff --git a/frontend/src/store/theme.ts b/frontend/src/store/theme.ts new file mode 100644 index 0000000..18e7d0f --- /dev/null +++ b/frontend/src/store/theme.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface ThemeState { + theme: "dark" | "light"; + toggle: () => void; + apply: () => void; +} + +export const useThemeStore = create()( + persist( + (set, get) => ({ + theme: "dark", + toggle: () => { + set({ theme: get().theme === "dark" ? "light" : "dark" }); + get().apply(); + }, + apply: () => { + const root = document.documentElement; + if (get().theme === "dark") root.classList.add("dark"); + else root.classList.remove("dark"); + }, + }), + { name: "stackpilot-theme" } + ) +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..2bb2482 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,78 @@ +export type StackStatus = + | "running" + | "partial" + | "stopped" + | "error" + | "updating" + | "unknown"; + +export interface StackSummary { + id: string; + name: string; + description?: string | null; + status: StackStatus; + service_count: number; + running_count: number; + created_at: string; + updated_at: string; +} + +export interface ContainerInfo { + id: string; + name: string; + service: string; + image: string; + state: string; + status: string; + health?: string | null; + ports: { container: string; host_ip?: string; host_port?: string | null }[]; + created?: string | null; +} + +export interface StackDetail { + id: string; + name: string; + description?: string | null; + status: StackStatus; + yaml: string; + env: string; + containers: ContainerInfo[]; + created_at: string; + updated_at: string; +} + +export interface SystemInfo { + docker_version: string; + host_os: string; + hostname: string; + cpu_cores: number; + ram: { total: number; available: number; used: number }; + disk: { total: number; used: number; free: number }; + uptime_seconds: number; + containers_running: number; + containers_total: number; + gpus: unknown[]; +} + +export interface AuditEntry { + id: number; + user: string; + action: string; + target: string; + detail?: string | null; + ip?: string | null; + timestamp: string; +} + +export interface User { + id: number; + username: string; + role: string; + is_active: boolean; +} + +export interface TokenPair { + access_token: string; + refresh_token: string; + token_type: string; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..c774025 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: "class", + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + bg: { DEFAULT: "#f8fafc", dark: "#0f172a" }, + card: { DEFAULT: "#ffffff", dark: "#1e293b" }, + accent: { DEFAULT: "#0284c7", dark: "#38bdf8" }, + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3ec161d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..9b1870e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "types": ["node"], + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..fe06f2b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + server: { + host: true, + port: 5173, + proxy: { + "/api": { target: "http://localhost:5008", changeOrigin: true }, + "/ws": { target: "ws://localhost:5008", ws: true }, + }, + }, +});