Initial commit: StackPilot Phase 1 (Core)

Self-hosted Docker Compose manager.
- Backend: FastAPI + docker-py + SQLite (JWT auth, file-first stacks,
  lifecycle, live status, WebSocket logs, docker-run converter, audit log)
- Frontend: React + Vite + Tailwind (login/setup, dashboard, stacks,
  stack detail, Monaco editor, dark/light theme)
- Deployment: docker-compose.yml, Dockerfiles, nginx reverse proxy

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:04:58 +00:00
co-authored by Claude Opus 4.8
commit f732cb080b
59 changed files with 3677 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPilot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://backend:5008;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 600s;
}
# WebSocket proxy
location /ws/ {
proxy_pass http://backend:5008;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
# Basic security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.62.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^20.17.10",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { Login } from "@/pages/Login";
import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { Networks, Images, Templates, Settings } from "@/pages/Placeholder";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
function RequireAuth() {
const token = useAuthStore((s) => s.accessToken);
return token ? <Outlet /> : <Navigate to="/login" replace />;
}
export default function App() {
const applyTheme = useThemeStore((s) => s.apply);
const fetchMe = useAuthStore((s) => s.fetchMe);
const token = useAuthStore((s) => s.accessToken);
useEffect(() => {
applyTheme();
}, [applyTheme]);
useEffect(() => {
if (token) fetchMe().catch(() => {});
}, [token, fetchMe]);
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/stacks" element={<Stacks />} />
<Route path="/stacks/new" element={<StackEditor />} />
<Route path="/stacks/:id" element={<StackDetail />} />
<Route path="/stacks/:id/edit" element={<StackEditor />} />
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
+50
View File
@@ -0,0 +1,50 @@
import axios, { AxiosError } from "axios";
import { useAuthStore } from "@/store/auth";
const api = axios.create({ baseURL: "/" });
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
let refreshing: Promise<string | null> | null = null;
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as any;
if (error.response?.status === 401 && original && !original._retry) {
original._retry = true;
if (!refreshing) {
refreshing = useAuthStore
.getState()
.refresh()
.finally(() => {
refreshing = null;
});
}
const newToken = await refreshing;
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);
export function apiErrorMessage(err: unknown): string {
const e = err as AxiosError<any>;
const detail = e?.response?.data?.detail;
if (typeof detail === "string") return detail;
if (detail?.detail) return `${detail.error}: ${detail.detail}`;
if (detail?.error) return detail.error;
return e?.message || "Unexpected error";
}
export default api;
+28
View File
@@ -0,0 +1,28 @@
import api from "./client";
import type { StackDetail, StackSummary } from "@/types";
export const stacksApi = {
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
get: (id: string) =>
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
api.post<StackSummary>("/api/stacks", body).then((r) => r.data),
update: (id: string, body: { name?: string; description?: string; yaml?: string; env?: string }) =>
api.put<StackSummary>(`/api/stacks/${id}`, body).then((r) => r.data),
remove: (id: string, deleteFiles = true) =>
api.delete(`/api/stacks/${id}?delete_files=${deleteFiles}`).then((r) => r.data),
clone: (id: string, name: string) =>
api.post(`/api/stacks/${id}/clone`, { name }).then((r) => r.data),
start: (id: string) => api.post(`/api/stacks/${id}/start`).then((r) => r.data),
stop: (id: string) => api.post(`/api/stacks/${id}/stop`).then((r) => r.data),
restart: (id: string) => api.post(`/api/stacks/${id}/restart`).then((r) => r.data),
pull: (id: string) => api.post(`/api/stacks/${id}/pull`).then((r) => r.data),
update_images: (id: string) => api.post(`/api/stacks/${id}/update`).then((r) => r.data),
down: (id: string) => api.post(`/api/stacks/${id}/down`).then((r) => r.data),
logs: (id: string, tail = 200) =>
api.get<{ logs: string }>(`/api/stacks/${id}/logs?tail=${tail}`).then((r) => r.data),
convert: (command: string) =>
api.post<{ yaml: string }>("/api/stacks/convert", { command }).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import api from "./client";
import type { AuditEntry, SystemInfo } from "@/types";
export const systemApi = {
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
audit: (limit = 10) =>
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
};
+17
View File
@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
export function Layout() {
return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Boxes,
Network,
Image,
LayoutTemplate,
Settings,
Moon,
Sun,
LogOut,
Ship,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
return (
<aside className="flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center gap-2 px-5 py-5">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span>
</div>
<nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-center justify-between px-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
{user?.username ?? "—"}
{user?.role === "admin" && (
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
admin
</span>
)}
</span>
<button
onClick={toggle}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useLocation } from "react-router-dom";
const titles: Record<string, string> = {
"": "Dashboard",
stacks: "Stacks",
networks: "Networks",
images: "Images",
templates: "Templates",
settings: "Settings",
};
export function Topbar() {
const { pathname } = useLocation();
const segment = pathname.split("/")[1] ?? "";
const title = titles[segment] ?? "StackPilot";
return (
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-card px-6 dark:border-slate-700 dark:bg-card-dark">
<h1 className="text-base font-semibold">{title}</h1>
</header>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from "react";
import { ArrowDownToLine, Pause, Play } from "lucide-react";
import { Button } from "@/components/ui";
import { useAuthStore } from "@/store/auth";
const MAX_LINES = 2000;
const serviceColors = [
"text-sky-400",
"text-emerald-400",
"text-amber-400",
"text-fuchsia-400",
"text-rose-400",
"text-lime-400",
];
function colorFor(service: string | null): string {
if (!service) return "text-slate-300";
let h = 0;
for (const c of service) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return serviceColors[h % serviceColors.length];
}
export function LogViewer({ stackId }: { stackId: string }) {
const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const token = useAuthStore((s) => s.accessToken);
useEffect(() => {
if (!token) return;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "log") {
setLines((prev) => {
const next = [...prev, { service: msg.service, line: msg.line }];
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
});
}
} catch {
/* ignore */
}
};
return () => ws.close();
}, [stackId, token]);
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [lines, autoScroll]);
const download = () => {
const blob = new Blob([lines.map((l) => l.line).join("\n")], {
type: "text/plain",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${stackId}-logs.txt`;
a.click();
};
return (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-slate-500">
{connected ? (
<span className="text-green-500"> live</span>
) : (
<span className="text-slate-400"> disconnected</span>
)}
<span className="ml-2">{lines.length} lines</span>
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
{autoScroll ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{autoScroll ? "Pause scroll" : "Auto-scroll"}
</Button>
<Button variant="outline" onClick={download}>
<ArrowDownToLine className="h-4 w-4" /> Download
</Button>
</div>
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
>
{lines.length === 0 && (
<div className="text-slate-500">Waiting for log output</div>
)}
{lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className="text-slate-200">{l.line}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import { Play, Square, RotateCw, Pencil } from "lucide-react";
import { Card, StatusDot, Badge } from "@/components/ui";
import { relativeTime } from "@/lib/utils";
import type { StackSummary } from "@/types";
interface Props {
stack: StackSummary;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
busy?: boolean;
isAdmin?: boolean;
}
export function StackCard({
stack,
onStart,
onStop,
onRestart,
busy,
isAdmin,
}: Props) {
return (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<Link to={`/stacks/${stack.id}`} className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="truncate font-semibold hover:underline">
{stack.name}
</span>
</div>
{stack.description && (
<p className="mt-1 truncate text-sm text-slate-500">
{stack.description}
</p>
)}
</Link>
<Badge status={stack.status}>{stack.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>
{stack.running_count}/{stack.service_count} services
</span>
<span>·</span>
<span>updated {relativeTime(stack.updated_at)}</span>
</div>
{isAdmin && (
<div className="flex gap-1 border-t border-slate-100 pt-3 dark:border-slate-700">
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4 text-sky-500" />
</IconBtn>
<Link
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
</div>
)}
</Card>
);
}
function IconBtn({
children,
title,
onClick,
disabled,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
{children}
</button>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { StackStatus } from "@/types";
export function Card({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
"dark:border-slate-700 dark:bg-card-dark",
className
)}
>
{children}
</div>
);
}
type Variant = "primary" | "ghost" | "danger" | "outline";
const variantClasses: Record<Variant, string> = {
primary:
"bg-accent text-white hover:bg-sky-600 dark:bg-accent-dark dark:text-slate-900 dark:hover:bg-sky-300",
ghost:
"bg-transparent text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700",
danger: "bg-red-600 text-white hover:bg-red-700",
outline:
"border border-slate-300 bg-transparent text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700",
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
loading?: boolean;
}
export function Button({
variant = "primary",
loading,
className,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
variantClasses[variant],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
export function Input({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none",
"focus:border-accent focus:ring-1 focus:ring-accent",
"dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-accent-dark",
className
)}
{...props}
/>
);
}
const statusColor: Record<StackStatus, string> = {
running: "bg-green-500",
partial: "bg-yellow-500",
stopped: "bg-slate-400",
error: "bg-red-500",
updating: "bg-sky-500 animate-pulse",
unknown: "bg-slate-300",
};
export function StatusDot({ status }: { status: StackStatus }) {
return (
<span
className={cn("inline-block h-2.5 w-2.5 rounded-full", statusColor[status])}
title={status}
/>
);
}
export function Badge({
status,
children,
}: {
status?: StackStatus;
children: ReactNode;
}) {
const tone = status
? {
running: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
partial: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
stopped: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300",
error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
updating: "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300",
unknown: "bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400",
}[status]
: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
tone
)}
>
{children}
</span>
);
}
export function Spinner() {
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-accent dark:text-accent-dark" />
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
export function useStackActions() {
const qc = useQueryClient();
const [busyId, setBusyId] = useState<string | null>(null);
const run = async (
id: string,
label: string,
fn: (id: string) => Promise<unknown>
) => {
setBusyId(id);
const t = toast.loading(`${label} ${id}`);
try {
await fn(id);
toast.success(`${label} ${id}`, { id: t });
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", id] });
} catch (err) {
toast.error(apiErrorMessage(err), { id: t });
} finally {
setBusyId(null);
}
};
return {
busyId,
start: (id: string) => run(id, "Starting", stacksApi.start),
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
pull: (id: string) => run(id, "Pulling", stacksApi.pull),
updateImages: (id: string) => run(id, "Updating", stacksApi.update_images),
down: (id: string) => run(id, "Tearing down", stacksApi.down),
};
}
+28
View File
@@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light dark;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.4);
border-radius: 4px;
}
+35
View File
@@ -0,0 +1,35 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
return `${m}m`;
}
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const s = Math.floor(diff / 1000);
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<Toaster position="top-right" richColors theme="system" />
</QueryClientProvider>
</React.StrictMode>
);
+131
View File
@@ -0,0 +1,131 @@
import { useQuery } from "@tanstack/react-query";
import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react";
import { Card, Spinner } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
return (
<div className="space-y-6">
{/* Resource bar */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
<Stat
icon={<MemoryStick className="h-5 w-5" />}
label="Memory"
value={
info.data
? `${formatBytes(info.data.ram.used)} / ${formatBytes(info.data.ram.total)}`
: "—"
}
/>
<Stat
icon={<Container className="h-5 w-5" />}
label="Containers"
value={
info.data
? `${info.data.containers_running} / ${info.data.containers_total}`
: "—"
}
/>
<Stat
icon={<HardDrive className="h-5 w-5" />}
label="Docker"
value={info.data?.docker_version ?? "—"}
/>
</div>
{/* Stacks grid */}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Stacks
</h2>
{stacks.isLoading ? (
<Spinner />
) : stacks.data && stacks.data.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{stacks.data.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">
No stacks yet. Create one from the Stacks page.
</p>
</Card>
)}
</section>
{/* Recent activity */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
{audit.data && audit.data.length > 0 ? (
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
{audit.data.map((a) => (
<li key={a.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium">{a.user}</span>{" "}
<span className="text-slate-500">{a.action}</span>{" "}
<span className="font-mono text-xs text-accent dark:text-accent-dark">
{a.target}
</span>
</span>
<span className="text-xs text-slate-400">
{relativeTime(a.timestamp)}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">No activity yet.</p>
)}
</Card>
</section>
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Card className="flex items-center gap-3">
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</Card>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import { Ship } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { useAuthStore } from "@/store/auth";
import { apiErrorMessage } from "@/api/client";
import { toast } from "sonner";
export function Login() {
const navigate = useNavigate();
const { login, setup, accessToken } = useAuthStore();
const [needsSetup, setNeedsSetup] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (accessToken) navigate("/");
axios
.get("/api/auth/needs-setup")
.then((r) => setNeedsSetup(r.data.needs_setup))
.catch(() => {});
}, [accessToken, navigate]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (needsSetup && password !== confirm) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
if (needsSetup) {
await setup(username, password);
toast.success("Admin account created");
} else {
await login(username, password);
}
navigate("/");
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center bg-bg dark:bg-bg-dark">
<Card className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<Ship className="h-10 w-10 text-accent dark:text-accent-dark" />
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
StackPilot
</h1>
<p className="text-sm text-slate-500">
{needsSetup ? "Create your admin account" : "Sign in to continue"}
</p>
</div>
<form onSubmit={submit} className="space-y-3">
<Input
placeholder="Username"
value={username}
autoFocus
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{needsSetup && (
<Input
type="password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
)}
<Button type="submit" loading={loading} className="w-full">
{needsSetup ? "Create account" : "Sign in"}
</Button>
</form>
</Card>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Construction } from "lucide-react";
import { Card } from "@/components/ui";
export function Placeholder({ title, phase }: { title: string; phase: string }) {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<Construction className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">{title}</h2>
<p className="max-w-md text-sm text-slate-500">
This section is part of {phase}. The backend foundation is ready the UI
lands in an upcoming build phase.
</p>
</Card>
);
}
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
+161
View File
@@ -0,0 +1,161 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Pencil,
Power,
} from "lucide-react";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { LogViewer } from "@/components/stacks/LogViewer";
import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
export function StackDetail() {
const { id = "" } = useParams();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const actions = useStackActions();
const { data, isLoading } = useQuery({
queryKey: ["stack", id],
queryFn: () => stacksApi.get(id),
refetchInterval: 5000,
});
if (isLoading || !data) return <Spinner />;
const busy = actions.busyId === id;
return (
<div className="flex h-full flex-col space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => actions.start(id)} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => actions.stop(id)} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => actions.restart(id)} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => actions.pull(id)} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => actions.updateImages(id)} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
</Button>
</Link>
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview data={data} />}
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
</div>
</div>
);
}
function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
return (
<div className="space-y-2 overflow-auto">
{data.containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">
No containers running. Start the stack to see services.
</p>
</Card>
)}
{data.containers.map((c: any) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
{c.ports.length > 0 && (
<span className="font-mono">
{c.ports
.filter((p: any) => p.host_port)
.map((p: any) => `${p.host_port}${p.container}`)
.join(", ")}
</span>
)}
</div>
</Card>
))}
</div>
);
}
function EnvView({ env }: { env: string }) {
return (
<Card className="h-full overflow-auto">
{env ? (
<pre className="whitespace-pre-wrap font-mono text-xs">{env}</pre>
) : (
<p className="text-sm text-slate-500">No .env file for this stack.</p>
)}
</Card>
);
}
function ComposeView({ yaml }: { yaml: string }) {
return (
<Card className="h-full overflow-auto">
<pre className="whitespace-pre-wrap font-mono text-xs">{yaml}</pre>
</Card>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Editor from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme";
import { toast } from "sonner";
const STARTER = `services:
app:
image: nginx:alpine
restart: unless-stopped
ports:
- "8080:80"
`;
export function StackEditor() {
const { id } = useParams();
const isNew = !id;
const navigate = useNavigate();
const qc = useQueryClient();
const theme = useThemeStore((s) => s.theme);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [yaml, setYaml] = useState(STARTER);
const [env, setEnv] = useState("");
const [tab, setTab] = useState<"compose" | "env">("compose");
const [saving, setSaving] = useState(false);
const [convertOpen, setConvertOpen] = useState(false);
const [runCmd, setRunCmd] = useState("");
const existing = useQuery({
queryKey: ["stack", id],
queryFn: () => stacksApi.get(id!),
enabled: !isNew,
});
useEffect(() => {
if (existing.data) {
setName(existing.data.name);
setDescription(existing.data.description ?? "");
setYaml(existing.data.yaml || STARTER);
setEnv(existing.data.env || "");
}
}, [existing.data]);
const save = async (deploy: boolean) => {
if (isNew && !name.trim()) {
toast.error("Stack name is required");
return;
}
setSaving(true);
try {
let stackId = id;
if (isNew) {
const created = await stacksApi.create({ name, description, yaml, env });
stackId = created.id;
} else {
await stacksApi.update(id!, { name, description, yaml, env });
}
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", stackId] });
toast.success("Saved");
if (deploy && stackId) {
const t = toast.loading("Deploying…");
await stacksApi.start(stackId);
toast.success("Deployed ✓", { id: t });
}
navigate(`/stacks/${stackId}`);
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
setSaving(false);
}
};
const convert = async () => {
try {
const { yaml: converted } = await stacksApi.convert(runCmd);
setYaml(converted);
setConvertOpen(false);
setRunCmd("");
toast.success("Converted to compose");
} catch (err) {
toast.error(apiErrorMessage(err));
}
};
return (
<div className="flex h-full flex-col space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Input
className="max-w-xs"
placeholder="Stack name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isNew}
/>
<Input
className="max-w-md flex-1"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
<Wand2 className="h-4 w-4" /> Convert docker run
</Button>
</div>
{convertOpen && (
<Card className="flex items-center gap-2">
<Input
placeholder="docker run -d --name web -p 8080:80 nginx:alpine"
value={runCmd}
onChange={(e) => setRunCmd(e.target.value)}
/>
<Button onClick={convert}>Convert</Button>
</Card>
)}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
<TabBtn active={tab === "compose"} onClick={() => setTab("compose")}>
<FileCode className="h-4 w-4" /> compose.yaml
</TabBtn>
<TabBtn active={tab === "env"} onClick={() => setTab("env")}>
.env
</TabBtn>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => save(false)} loading={saving}>
<Save className="h-4 w-4" /> Save Draft
</Button>
<Button onClick={() => save(true)} loading={saving}>
<Rocket className="h-4 w-4" /> Deploy
</Button>
</div>
</div>
);
}
function TabBtn({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={
active
? "flex items-center gap-1 border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "flex items-center gap-1 px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{children}
</button>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { Button, Input, Spinner, Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
type SortKey = "name" | "status" | "updated";
export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortKey>("name");
const { data, isLoading } = useQuery({
queryKey: ["stacks"],
queryFn: stacksApi.list,
refetchInterval: 5000,
});
const filtered = useMemo(() => {
let list = (data ?? []).filter(
(s) =>
s.name.toLowerCase().includes(q.toLowerCase()) ||
s.id.toLowerCase().includes(q.toLowerCase())
);
list = [...list].sort((a, b) => {
if (sort === "name") return a.name.localeCompare(b.name);
if (sort === "status") return a.status.localeCompare(b.status);
return b.updated_at.localeCompare(a.updated_at);
});
return list;
}, [data, q, sort]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
className="pl-9"
placeholder="Search stacks…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
<option value="updated">Sort: Last updated</option>
</select>
{isAdmin && (
<Link to="/stacks/new">
<Button>
<Plus className="h-4 w-4" /> New Stack
</Button>
</Link>
)}
</div>
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import axios from "axios";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TokenPair, User } from "@/types";
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: User | null;
setTokens: (t: TokenPair) => void;
login: (username: string, password: string) => Promise<void>;
setup: (username: string, password: string) => Promise<void>;
refresh: () => Promise<string | null>;
fetchMe: () => Promise<void>;
logout: () => void;
}
// Raw client without interceptors (avoids refresh loops).
const raw = axios.create({ baseURL: "/" });
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
setTokens: (t) =>
set({ accessToken: t.access_token, refreshToken: t.refresh_token }),
login: async (username, password) => {
const { data } = await raw.post<TokenPair>("/api/auth/login", {
username,
password,
});
set({ accessToken: data.access_token, refreshToken: data.refresh_token });
await get().fetchMe();
},
setup: async (username, password) => {
const { data } = await raw.post<TokenPair>("/api/auth/setup", {
username,
password,
role: "admin",
});
set({ accessToken: data.access_token, refreshToken: data.refresh_token });
await get().fetchMe();
},
refresh: async () => {
const rt = get().refreshToken;
if (!rt) return null;
try {
const { data } = await raw.post<TokenPair>("/api/auth/refresh", {
refresh_token: rt,
});
set({ accessToken: data.access_token, refreshToken: data.refresh_token });
return data.access_token;
} catch {
set({ accessToken: null, refreshToken: null, user: null });
return null;
}
},
fetchMe: async () => {
const token = get().accessToken;
if (!token) return;
const { data } = await raw.get<User>("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
set({ user: data });
},
logout: () => set({ accessToken: null, refreshToken: null, user: null }),
}),
{ name: "stackpilot-auth" }
)
);
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface ThemeState {
theme: "dark" | "light";
toggle: () => void;
apply: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: "dark",
toggle: () => {
set({ theme: get().theme === "dark" ? "light" : "dark" });
get().apply();
},
apply: () => {
const root = document.documentElement;
if (get().theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
},
}),
{ name: "stackpilot-theme" }
)
);
+78
View File
@@ -0,0 +1,78 @@
export type StackStatus =
| "running"
| "partial"
| "stopped"
| "error"
| "updating"
| "unknown";
export interface StackSummary {
id: string;
name: string;
description?: string | null;
status: StackStatus;
service_count: number;
running_count: number;
created_at: string;
updated_at: string;
}
export interface ContainerInfo {
id: string;
name: string;
service: string;
image: string;
state: string;
status: string;
health?: string | null;
ports: { container: string; host_ip?: string; host_port?: string | null }[];
created?: string | null;
}
export interface StackDetail {
id: string;
name: string;
description?: string | null;
status: StackStatus;
yaml: string;
env: string;
containers: ContainerInfo[];
created_at: string;
updated_at: string;
}
export interface SystemInfo {
docker_version: string;
host_os: string;
hostname: string;
cpu_cores: number;
ram: { total: number; available: number; used: number };
disk: { total: number; used: number; free: number };
uptime_seconds: number;
containers_running: number;
containers_total: number;
gpus: unknown[];
}
export interface AuditEntry {
id: number;
user: string;
action: string;
target: string;
detail?: string | null;
ip?: string | null;
timestamp: string;
}
export interface User {
id: number;
username: string;
role: string;
is_active: boolean;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
token_type: string;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: { DEFAULT: "#f8fafc", dark: "#0f172a" },
card: { DEFAULT: "#ffffff", dark: "#1e293b" },
accent: { DEFAULT: "#0284c7", dark: "#38bdf8" },
},
},
},
plugins: [],
} satisfies Config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
host: true,
port: 5173,
proxy: {
"/api": { target: "http://localhost:5008", changeOrigin: true },
"/ws": { target: "ws://localhost:5008", ws: true },
},
},
});