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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:04:58 +00:00
co-authored by Claude Opus 4.8
commit f732cb080b
59 changed files with 3677 additions and 0 deletions
+11
View File
@@ -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
+25
View File
@@ -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/
+105
View File
@@ -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 <http://localhost:5009> 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.
+6
View File
@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
data
*.db
.env
+30
View File
@@ -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"]
+132
View File
@@ -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
+69
View File
@@ -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()
+31
View File
@@ -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
+50
View File
@@ -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
+62
View File
@@ -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"}
+6
View File
@@ -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"]
+20
View File
@@ -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)
+49
View File
@@ -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
+50
View File
@@ -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
+13
View File
@@ -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
View File
+29
View File
@@ -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()
+111
View File
@@ -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
+334
View File
@@ -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
+92
View File
@@ -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
}
+130
View File
@@ -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()
View File
+22
View File
@@ -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()
+366
View File
@@ -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)
+135
View File
@@ -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)
+30
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log
+11
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPilot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -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;
}
+35
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+52
View File
@@ -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 ? <Outlet /> : <Navigate to="/login" replace />;
}
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 (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/stacks" element={<Stacks />} />
<Route path="/stacks/new" element={<StackEditor />} />
<Route path="/stacks/:id" element={<StackDetail />} />
<Route path="/stacks/:id/edit" element={<StackEditor />} />
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
+50
View File
@@ -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<string | null> | 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<any>;
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;
+28
View File
@@ -0,0 +1,28 @@
import api from "./client";
import type { StackDetail, StackSummary } from "@/types";
export const stacksApi = {
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
get: (id: string) =>
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
api.post<StackSummary>("/api/stacks", body).then((r) => r.data),
update: (id: string, body: { name?: string; description?: string; yaml?: string; env?: string }) =>
api.put<StackSummary>(`/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),
};
+8
View File
@@ -0,0 +1,8 @@
import api from "./client";
import type { AuditEntry, SystemInfo } from "@/types";
export const systemApi = {
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
audit: (limit = 10) =>
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
};
+17
View File
@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
export function Layout() {
return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -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 (
<aside className="flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center gap-2 px-5 py-5">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span>
</div>
<nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-center justify-between px-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
{user?.username ?? "—"}
{user?.role === "admin" && (
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
admin
</span>
)}
</span>
<button
onClick={toggle}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useLocation } from "react-router-dom";
const titles: Record<string, string> = {
"": "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 (
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-card px-6 dark:border-slate-700 dark:bg-card-dark">
<h1 className="text-base font-semibold">{title}</h1>
</header>
);
}
@@ -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<HTMLDivElement>(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 (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-slate-500">
{connected ? (
<span className="text-green-500"> live</span>
) : (
<span className="text-slate-400"> disconnected</span>
)}
<span className="ml-2">{lines.length} lines</span>
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
{autoScroll ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{autoScroll ? "Pause scroll" : "Auto-scroll"}
</Button>
<Button variant="outline" onClick={download}>
<ArrowDownToLine className="h-4 w-4" /> Download
</Button>
</div>
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
>
{lines.length === 0 && (
<div className="text-slate-500">Waiting for log output</div>
)}
{lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className="text-slate-200">{l.line}</span>
</div>
))}
</div>
</div>
);
}
@@ -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 (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<Link to={`/stacks/${stack.id}`} className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="truncate font-semibold hover:underline">
{stack.name}
</span>
</div>
{stack.description && (
<p className="mt-1 truncate text-sm text-slate-500">
{stack.description}
</p>
)}
</Link>
<Badge status={stack.status}>{stack.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>
{stack.running_count}/{stack.service_count} services
</span>
<span>·</span>
<span>updated {relativeTime(stack.updated_at)}</span>
</div>
{isAdmin && (
<div className="flex gap-1 border-t border-slate-100 pt-3 dark:border-slate-700">
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4 text-sky-500" />
</IconBtn>
<Link
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
</div>
)}
</Card>
);
}
function IconBtn({
children,
title,
onClick,
disabled,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
{children}
</button>
);
}
+138
View File
@@ -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 (
<div
className={cn(
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
"dark:border-slate-700 dark:bg-card-dark",
className
)}
>
{children}
</div>
);
}
type Variant = "primary" | "ghost" | "danger" | "outline";
const variantClasses: Record<Variant, string> = {
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<HTMLButtonElement> {
variant?: Variant;
loading?: boolean;
}
export function Button({
variant = "primary",
loading,
className,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
variantClasses[variant],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
export function Input({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none",
"focus:border-accent focus:ring-1 focus:ring-accent",
"dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-accent-dark",
className
)}
{...props}
/>
);
}
const statusColor: Record<StackStatus, string> = {
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 (
<span
className={cn("inline-block h-2.5 w-2.5 rounded-full", statusColor[status])}
title={status}
/>
);
}
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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
tone
)}
>
{children}
</span>
);
}
export function Spinner() {
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-accent dark:text-accent-dark" />
</div>
);
}
+39
View File
@@ -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<string | null>(null);
const run = async (
id: string,
label: string,
fn: (id: string) => Promise<unknown>
) => {
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),
};
}
+28
View File
@@ -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;
}
+35
View File
@@ -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`;
}
+19
View File
@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<Toaster position="top-right" richColors theme="system" />
</QueryClientProvider>
</React.StrictMode>
);
+131
View File
@@ -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 (
<div className="space-y-6">
{/* Resource bar */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
<Stat
icon={<MemoryStick className="h-5 w-5" />}
label="Memory"
value={
info.data
? `${formatBytes(info.data.ram.used)} / ${formatBytes(info.data.ram.total)}`
: "—"
}
/>
<Stat
icon={<Container className="h-5 w-5" />}
label="Containers"
value={
info.data
? `${info.data.containers_running} / ${info.data.containers_total}`
: "—"
}
/>
<Stat
icon={<HardDrive className="h-5 w-5" />}
label="Docker"
value={info.data?.docker_version ?? "—"}
/>
</div>
{/* Stacks grid */}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Stacks
</h2>
{stacks.isLoading ? (
<Spinner />
) : stacks.data && stacks.data.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{stacks.data.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">
No stacks yet. Create one from the Stacks page.
</p>
</Card>
)}
</section>
{/* Recent activity */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
{audit.data && audit.data.length > 0 ? (
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
{audit.data.map((a) => (
<li key={a.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium">{a.user}</span>{" "}
<span className="text-slate-500">{a.action}</span>{" "}
<span className="font-mono text-xs text-accent dark:text-accent-dark">
{a.target}
</span>
</span>
<span className="text-xs text-slate-400">
{relativeTime(a.timestamp)}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">No activity yet.</p>
)}
</Card>
</section>
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Card className="flex items-center gap-3">
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</Card>
);
}
+89
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-bg dark:bg-bg-dark">
<Card className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<Ship className="h-10 w-10 text-accent dark:text-accent-dark" />
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
StackPilot
</h1>
<p className="text-sm text-slate-500">
{needsSetup ? "Create your admin account" : "Sign in to continue"}
</p>
</div>
<form onSubmit={submit} className="space-y-3">
<Input
placeholder="Username"
value={username}
autoFocus
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{needsSetup && (
<Input
type="password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
)}
<Button type="submit" loading={loading} className="w-full">
{needsSetup ? "Create account" : "Sign in"}
</Button>
</form>
</Card>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Construction } from "lucide-react";
import { Card } from "@/components/ui";
export function Placeholder({ title, phase }: { title: string; phase: string }) {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<Construction className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">{title}</h2>
<p className="max-w-md text-sm text-slate-500">
This section is part of {phase}. The backend foundation is ready the UI
lands in an upcoming build phase.
</p>
</Card>
);
}
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
+161
View File
@@ -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<Tab>("Overview");
const actions = useStackActions();
const { data, isLoading } = useQuery({
queryKey: ["stack", id],
queryFn: () => stacksApi.get(id),
refetchInterval: 5000,
});
if (isLoading || !data) return <Spinner />;
const busy = actions.busyId === id;
return (
<div className="flex h-full flex-col space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => actions.start(id)} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => actions.stop(id)} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => actions.restart(id)} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => actions.pull(id)} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => actions.updateImages(id)} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
</Button>
</Link>
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview data={data} />}
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
</div>
</div>
);
}
function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
return (
<div className="space-y-2 overflow-auto">
{data.containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">
No containers running. Start the stack to see services.
</p>
</Card>
)}
{data.containers.map((c: any) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
{c.ports.length > 0 && (
<span className="font-mono">
{c.ports
.filter((p: any) => p.host_port)
.map((p: any) => `${p.host_port}${p.container}`)
.join(", ")}
</span>
)}
</div>
</Card>
))}
</div>
);
}
function EnvView({ env }: { env: string }) {
return (
<Card className="h-full overflow-auto">
{env ? (
<pre className="whitespace-pre-wrap font-mono text-xs">{env}</pre>
) : (
<p className="text-sm text-slate-500">No .env file for this stack.</p>
)}
</Card>
);
}
function ComposeView({ yaml }: { yaml: string }) {
return (
<Card className="h-full overflow-auto">
<pre className="whitespace-pre-wrap font-mono text-xs">{yaml}</pre>
</Card>
);
}
+189
View File
@@ -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 (
<div className="flex h-full flex-col space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Input
className="max-w-xs"
placeholder="Stack name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isNew}
/>
<Input
className="max-w-md flex-1"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
<Wand2 className="h-4 w-4" /> Convert docker run
</Button>
</div>
{convertOpen && (
<Card className="flex items-center gap-2">
<Input
placeholder="docker run -d --name web -p 8080:80 nginx:alpine"
value={runCmd}
onChange={(e) => setRunCmd(e.target.value)}
/>
<Button onClick={convert}>Convert</Button>
</Card>
)}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
<TabBtn active={tab === "compose"} onClick={() => setTab("compose")}>
<FileCode className="h-4 w-4" /> compose.yaml
</TabBtn>
<TabBtn active={tab === "env"} onClick={() => setTab("env")}>
.env
</TabBtn>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => save(false)} loading={saving}>
<Save className="h-4 w-4" /> Save Draft
</Button>
<Button onClick={() => save(true)} loading={saving}>
<Rocket className="h-4 w-4" /> Deploy
</Button>
</div>
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={
active
? "flex items-center gap-1 border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "flex items-center gap-1 px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{children}
</button>
);
}
+92
View File
@@ -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<SortKey>("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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
className="pl-9"
placeholder="Search stacks…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
<option value="updated">Sort: Last updated</option>
</select>
{isAdmin && (
<Link to="/stacks/new">
<Button>
<Plus className="h-4 w-4" /> New Stack
</Button>
</Link>
)}
</div>
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
</div>
);
}
+78
View File
@@ -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<void>;
setup: (username: string, password: string) => Promise<void>;
refresh: () => Promise<string | null>;
fetchMe: () => Promise<void>;
logout: () => void;
}
// Raw client without interceptors (avoids refresh loops).
const raw = axios.create({ baseURL: "/" });
export const useAuthStore = create<AuthState>()(
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<TokenPair>("/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<TokenPair>("/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<TokenPair>("/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<User>("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
set({ user: data });
},
logout: () => set({ accessToken: null, refreshToken: null, user: null }),
}),
{ name: "stackpilot-auth" }
)
);
+26
View File
@@ -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<ThemeState>()(
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" }
)
);
+78
View File
@@ -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;
}
+16
View File
@@ -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;
+23
View File
@@ -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" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -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 },
},
},
});