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
@@ -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>
);
}