Run compose like the terminal does, and follow changes made there (0.67.2)
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:
1 parent
d6cebb3371
commit
06234c1067
21 files changed
+811
-45
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.67.1",
|
||||
"version": "0.67.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user