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
+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),
};