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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user