Run compose like the terminal does, and follow changes made there (0.67.2)
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s

StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.

Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.

Changes made outside StackPilot now reach every view:

- docker_watch_service reads Docker's events once for the backend. The
  dashboard rollup (cached 25s) and the stats cache are stale as soon as
  the event generation moves; the tiles followed a terminal `compose
  stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
  a terminal clears the update badge instead of leaving it until the
  next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
  while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
  saving after they changed on disk is refused with 409 and the editor
  offers to overwrite or to load the version on disk. A save used to
  replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
  passing for an ordinary stopped stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
This commit is contained in:
menzeljandClaude Opus 5.5 committed 2026-10-15 15:14:40 +02:00
1 parent d6cebb3371
commit 06234c1067
21 files changed
+811 -45

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.67.1",
"version": "0.67.2",
"type": "module",
"scripts": {
"dev": "vite",
+4 -1
View File
@@ -31,8 +31,11 @@ export const stacksApi = {
icon?: string;
yaml?: string;
env?: string;
/** The revision the editor loaded; a 409 "changed_on_disk" if the files
* changed since. Left out to overwrite regardless. */
base_revision?: string;
}
) => api.put<StackSummary>(`/api/stacks/${id}`, body).then((r) => r.data),
) => api.put<StackSummary & { revision?: string }>(`/api/stacks/${id}`, body).then((r) => r.data),
/** `kept` lists folders left in place because another filesystem (a share,
* a second disk) is mounted there. */
remove: (id: string, deleteFiles = true) =>
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Play, Square, RotateCw, ArrowUpCircle, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card, Spinner, Badge } from "@/components/ui";
import { Card, Spinner, Badge, MissingBadge } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { StackIcon } from "@/components/ui/StackIcon";
import { formatBytes } from "@/lib/utils";
@@ -171,6 +171,7 @@ function StackRow({
<StackIcon stack={stack} status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
{stack.missing && <MissingBadge />}
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
+13
View File
@@ -125,6 +125,19 @@ export function Badge({
);
}
/** For a stack whose folder no longer exists — deleted outside StackPilot,
* or a share that is not mounted. */
export function MissingBadge() {
return (
<span
title="The stack's folder no longer exists — deleted outside StackPilot, or its share is not mounted. Delete the stack here to drop it from the list."
className="rounded-pill border border-red-500/40 bg-red-500/10 px-2 py-0.5 text-[11px] font-semibold text-red-600 dark:text-red-400"
>
folder missing
</span>
);
}
export function Spinner() {
return (
<div className="flex h-full w-full items-center justify-center p-8">
+14
View File
@@ -52,6 +52,8 @@ export function useDockerEvents() {
let retry: number | null = null;
let delay = RECONNECT_MIN_MS;
let closed = false;
// Set once a socket has been up: a later "ready" is a reconnect.
let connectedBefore = false;
const flush = () => {
timer.current = null;
@@ -84,6 +86,18 @@ export function useDockerEvents() {
socket.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "ready") {
// Whatever happened while the socket was down (a backend restart,
// a laptop asleep, a proxy dropping it) sent no event to us, and
// the pages kept showing the state from before until their slow
// poll. Catch up once, as if everything had changed.
if (connectedBefore) {
for (const resource of Object.keys(INVALIDATES)) pending.current.add(resource);
if (timer.current === null) timer.current = window.setTimeout(flush, COALESCE_MS);
}
connectedBefore = true;
return;
}
if (msg.type !== "event" || !msg.resource) return;
pending.current.add(msg.resource);
if (timer.current === null) {
+2 -1
View File
@@ -13,7 +13,7 @@ import {
LayoutTemplate,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { Badge, Button, Card, Input, MissingBadge, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
@@ -64,6 +64,7 @@ export function StackDetail() {
<div className="flex items-center gap-2">
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
{data.missing && <MissingBadge />}
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
+61 -2
View File
@@ -78,6 +78,10 @@ function StackEditorForm() {
// current values below, because the navigation right after a save is checked
// against a render from before it.
const saved = useRef({ name: "", description: "", yaml: STARTER, env: "" });
// The files' revision when they were loaded (or last saved) here.
const baseRevision = useRef<string | null>(null);
// A save refused because the files changed on disk meanwhile.
const [conflict, setConflict] = useState<{ deploy: boolean } | null>(null);
const loadedFor = useRef<string | null>(null);
useEffect(() => {
if (existing.data && id && loadedFor.current !== id) {
@@ -89,6 +93,7 @@ function StackEditorForm() {
env: existing.data.env || "",
};
saved.current = loaded;
baseRevision.current = existing.data.revision ?? null;
setName(loaded.name);
setDescription(loaded.description);
setIcon(existing.data.icon ?? null);
@@ -122,7 +127,7 @@ function StackEditorForm() {
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [unsaved]);
const save = async (deploy: boolean) => {
const save = async (deploy: boolean, overwrite = false) => {
if (isNew && !name.trim()) {
toast.error("Stack name is required");
return;
@@ -146,7 +151,15 @@ function StackEditorForm() {
});
stackId = created.id;
} else {
await stacksApi.update(id!, { name, description, icon: iconField, yaml, env });
const updated = await stacksApi.update(id!, {
name,
description,
icon: iconField,
yaml,
env,
base_revision: overwrite ? undefined : baseRevision.current ?? undefined,
});
baseRevision.current = updated.revision ?? null;
}
saved.current = { name, description, yaml, env };
if (iconFile && stackId) {
@@ -164,12 +177,38 @@ function StackEditorForm() {
}
navigate(`/stacks/${stackId}`);
} catch (err) {
const detail = (err as { response?: { status?: number; data?: { detail?: { error?: string } } } })
?.response;
if (detail?.status === 409 && detail.data?.detail?.error === "changed_on_disk") {
setConflict({ deploy });
return;
}
toast.error(apiErrorMessage(err));
} finally {
setSaving(false);
}
};
// Take the files as they are on disk now, dropping what was typed here.
const loadFromDisk = async () => {
const { data } = await existing.refetch();
setConflict(null);
if (!data) return;
const loaded = {
name: data.name,
description: data.description ?? "",
yaml: data.yaml || STARTER,
env: data.env || "",
};
saved.current = loaded;
baseRevision.current = data.revision ?? null;
setName(loaded.name);
setDescription(loaded.description);
setYaml(loaded.yaml);
setEnv(loaded.env);
toast.success("Loaded the version on disk");
};
const onDeploy = async () => {
setChecking(true);
try {
@@ -406,6 +445,26 @@ function StackEditorForm() {
/>
)}
{conflict && (
<ConfirmDialog
title="Changed outside StackPilot"
message="The compose file or .env was changed on disk — in a terminal, by git or another tab — after it was opened here. Saving now would replace that change with this version."
confirmLabel={conflict.deploy ? "Overwrite and deploy" : "Overwrite"}
danger
busy={saving}
onConfirm={() => {
const { deploy } = conflict;
setConflict(null);
save(deploy, true);
}}
onCancel={() => setConflict(null)}
>
<Button variant="outline" onClick={loadFromDisk}>
Load the version on disk (discard the edits here)
</Button>
</ConfirmDialog>
)}
{blocker.state === "blocked" && (
<ConfirmDialog
title="Discard unsaved changes?"
+7
View File
@@ -19,6 +19,8 @@ export interface StackSummary {
status: StackStatus;
service_count: number;
running_count: number;
/** Its folder no longer exists (deleted outside StackPilot, share unmounted). */
missing?: boolean;
created_at: string;
updated_at: string;
}
@@ -86,8 +88,13 @@ export interface StackDetail {
status: StackStatus;
/** Set while an operation holds the stack; null otherwise. */
lock?: StackLock | null;
/** Its folder no longer exists. */
missing?: boolean;
yaml: string;
env: string;
/** Fingerprint of the compose file and .env on disk (admins only); sent
* back on save so a change made elsewhere is not overwritten. */
revision?: string | null;
containers: ContainerInfo[];
created_at: string;
updated_at: string;