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:
@@ -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
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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" }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "./src") },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": { target: "http://localhost:5008", changeOrigin: true },
|
||||||
|
"/ws": { target: "ws://localhost:5008", ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user