Files
sencho/frontend/src/components/stack/GitSourcePanel.tsx
T
Anso 48cebf9501 fix: bind deploy progress, request, and health gate to the captured node (#1357)
* fix: bind deploy progress, request, and health gate to the captured node

A deploy/update/install/git-apply re-read the active node from localStorage
independently at three points: the progress WebSocket at mount, the POST at call
time, and the health-gate poll. If the active node changed between the click and
any of those, the operation, its live output, and its health verdict could
target different nodes, and the socket and POST splitting across nodes broke
output streaming.

Capture the operation's node once when it starts and thread it through every
leg. A new nodeId option on apiFetch overrides the active-node read, the
progress terminal takes a nodeId prop for its socket URL, the health gate polls
on the captured node, and a failed gate records its recovery entry only on the
node it ran on. The surface, the request, and the gate now always agree.

* fix: scope failed-gate recovery to the file list's node and harden node targeting

Addresses review findings on the captured-node binding:

- Track the node the stack file list was fetched for (filesNodeId) and record a
  failed gate's recovery entry only when it matches the gate's node. This closes
  a race where switching back to the gate's node could match a same-named stack
  from the previous node's still-loaded list before the new list lands, keying
  the record to the wrong file and blocking the correct one. refreshStacks now
  carries a sequence token so an out-of-order resolution cannot leave files and
  filesNodeId inconsistent.
- Make an explicit apiFetch nodeId authoritative over a caller-supplied
  x-node-id header.
- Add the missing stack-logs nodeId cases (null, and active-node fallback) to the
  terminal tests.
2026-06-11 14:36:20 -04:00

455 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { GitBranch, Loader2, Trash2, RefreshCw, Save, AlertCircle } from 'lucide-react';
import { Modal, ModalHeader, ConfirmModal } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useNodes } from '@/context/NodeContext';
import { toast } from '@/components/ui/toast-store';
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
export interface GitSource {
id: number;
stack_name: string;
repo_url: string;
branch: string;
compose_path: string;
sync_env: boolean;
env_path: string | null;
auth_type: 'none' | 'token';
has_token: boolean;
auto_apply_on_webhook: boolean;
auto_deploy_on_apply: boolean;
last_applied_commit_sha: string | null;
pending_commit_sha: string | null;
pending_fetched_at: number | null;
created_at: number;
updated_at: number;
}
interface GitSourcePanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
canEdit: boolean;
isDarkMode: boolean;
/** Called after any change that may affect the sidebar pending-badge. */
onSourceChanged?: () => void;
}
function deriveApplyMode(source: GitSource | null, pendingMode: ApplyMode | null): ApplyMode {
if (pendingMode) return pendingMode;
if (!source) return 'review';
if (!source.auto_apply_on_webhook) return 'review';
return source.auto_deploy_on_apply ? 'auto-deploy' : 'auto-write';
}
export function GitSourcePanel({
open,
onOpenChange,
stackName,
canEdit,
isDarkMode,
onSourceChanged,
}: GitSourcePanelProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [pulling, setPulling] = useState(false);
const [applying, setApplying] = useState(false);
const [source, setSource] = useState<GitSource | null>(null);
const [repoUrl, setRepoUrl] = useState('');
const [branch, setBranch] = useState('main');
const [composePath, setComposePath] = useState('compose.yaml');
const [syncEnv, setSyncEnv] = useState(false);
const [authType, setAuthType] = useState<'none' | 'token'>('none');
const [token, setToken] = useState('');
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
const [pull, setPull] = useState<PullResult | null>(null);
const [diffOpen, setDiffOpen] = useState(false);
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
const { runWithLog } = useDeployFeedback();
const { activeNode } = useNodes();
const applyMode = deriveApplyMode(source, applyModeOverride);
const resetToUnlinked = useCallback(() => {
setSource(null);
setRepoUrl('');
setBranch('main');
setComposePath('compose.yaml');
setSyncEnv(false);
setAuthType('none');
setToken('');
setApplyModeOverride(null);
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`);
if (res.ok) {
const data: GitSource | { linked: false } = await res.json();
// An existing stack with no Git source attached answers 200 { linked: false }.
if ('linked' in data) {
resetToUnlinked();
} else {
setSource(data);
setRepoUrl(data.repo_url);
setBranch(data.branch);
setComposePath(data.compose_path);
setSyncEnv(data.sync_env);
setAuthType(data.auth_type);
setToken('');
setApplyModeOverride(null);
}
} else if (res.status === 404) {
resetToUnlinked();
} else if (res.status === 403) {
setSource(null);
toast.error('You do not have permission to view this stack\'s Git source.');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to load Git source.');
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setLoading(false);
}
}, [stackName, resetToUnlinked]);
useEffect(() => {
if (open) {
void load();
}
}, [open, load]);
const save = async () => {
if (!repoUrl.trim() || !branch.trim() || !composePath.trim()) {
toast.error('Repository URL, branch, and compose path are required.');
return;
}
if (!/^https:\/\//i.test(repoUrl.trim())) {
toast.error('Only HTTPS repository URLs are supported.');
return;
}
setSaving(true);
const loadingId = toast.loading('Verifying repository access...');
try {
const autoApply = applyMode !== 'review';
const autoDeploy = applyMode === 'auto-deploy';
const body: Record<string, unknown> = {
repo_url: repoUrl.trim(),
branch: branch.trim(),
compose_path: composePath.trim(),
sync_env: syncEnv,
auth_type: authType,
auto_apply_on_webhook: autoApply,
auto_deploy_on_apply: autoDeploy,
};
if (authType === 'token' && token !== '') {
body.token = token;
}
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
method: 'PUT',
body: JSON.stringify(body),
});
if (res.ok) {
const data: GitSource = await res.json();
setSource(data);
setToken('');
setApplyModeOverride(null);
toast.success('Git source saved.');
onSourceChanged?.();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to save Git source.');
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
toast.dismiss(loadingId);
setSaving(false);
}
};
const remove = async () => {
if (!source) return;
setRemoveConfirmOpen(false);
setDeleting(true);
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
method: 'DELETE',
});
if (res.ok) {
toast.success('Git source removed.');
setSource(null);
onSourceChanged?.();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to remove Git source.');
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setDeleting(false);
}
};
const pullNow = async () => {
if (!source) return;
setPulling(true);
const loadingId = toast.loading('Fetching from Git...');
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/pull`, {
method: 'POST',
});
if (res.ok) {
const data: PullResult = await res.json();
setPull(data);
setDiffOpen(true);
onSourceChanged?.();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Pull failed.');
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
toast.dismiss(loadingId);
setPulling(false);
}
};
const applyPull = async (commitSha: string, deploy: boolean) => {
setApplying(true);
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
// Snapshot the node once so the apply (and any deploy it triggers) stays
// bound to it even if the active node changes while the operation runs.
const opNodeId = activeNode?.id ?? null;
try {
const runApply = async (started: Promise<void>) => {
if (deploy) await started;
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
method: 'POST',
nodeId: opNodeId,
body: JSON.stringify({ commitSha, deploy }),
});
if (res.ok) {
const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json();
if (data.deployError) {
toast.warning(`Applied, but deploy failed: ${data.deployError}`);
} else if (deploy && data.deployed) {
toast.success('Changes applied and deployed.');
} else {
toast.success('Changes applied.');
}
setDiffOpen(false);
setPull(null);
await load();
onSourceChanged?.();
return { ok: true };
} else {
const err = await res.json().catch(() => ({}));
const msg = (err as { error?: string }).error || 'Failed to apply changes.';
toast.error(msg);
return { ok: false, errorMessage: msg };
}
};
if (deploy) {
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, runApply);
} else {
await runApply(Promise.resolve());
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
toast.dismiss(loadingId);
setApplying(false);
}
};
const dismissPending = async () => {
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/dismiss-pending`, {
method: 'POST',
});
if (res.ok) {
setDiffOpen(false);
setPull(null);
await load();
onSourceChanged?.();
toast.success('Pending update dismissed.');
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
}
};
return (
<>
<Modal open={open} onOpenChange={onOpenChange} size="xl">
<ModalHeader
kicker={`${stackName.toUpperCase()} · GIT SOURCE`}
title={
<span className="flex items-center gap-2">
<GitBranch className="w-5 h-5" strokeWidth={1.5} />
Git source
</span>
}
description="Link this stack to a Git repository so compose updates can be pulled on demand or via webhook."
/>
<ScrollArea className="max-h-[70vh]">
<div className="px-6 py-5 space-y-5">
{loading ? (
<div className="space-y-3">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<>
{source?.pending_commit_sha && (
<div className="flex items-start gap-2 rounded-md border border-brand/30 bg-brand/5 px-3 py-2 text-xs shadow-card-bevel">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-brand" strokeWidth={1.5} />
<div className="flex-1">
<p className="font-medium">Pending update</p>
<p className="text-stat-subtitle mt-0.5">
Commit <span className="font-mono tabular-nums">{source.pending_commit_sha.slice(0, 7)}</span> is ready to review.
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => pullNow()}
disabled={pulling}
>
Review
</Button>
</div>
)}
<GitSourceFields
variant="edit"
disabled={!canEdit || saving}
repoUrl={repoUrl}
branch={branch}
composePath={composePath}
syncEnv={syncEnv}
authType={authType}
token={token}
hasStoredToken={source?.has_token ?? false}
applyMode={applyMode}
onRepoUrlChange={setRepoUrl}
onBranchChange={setBranch}
onComposePathChange={setComposePath}
onSyncEnvChange={setSyncEnv}
onAuthTypeChange={setAuthType}
onTokenChange={setToken}
onApplyModeChange={setApplyModeOverride}
/>
{source && (
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5 shadow-card-bevel">
<div className="flex justify-between gap-2">
<span>Last applied commit</span>
<span className="font-mono tabular-nums">
{source.last_applied_commit_sha ? source.last_applied_commit_sha.slice(0, 7) : 'never'}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Updated</span>
<span className="font-mono tabular-nums">
{new Date(source.updated_at).toLocaleString()}
</span>
</div>
</div>
)}
</>
)}
</div>
</ScrollArea>
<div className="px-6 py-4 border-t border-glass-border flex items-center justify-between gap-2">
<div>
{source && canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => setRemoveConfirmOpen(true)}
disabled={deleting || saving}
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Remove
</Button>
)}
</div>
<div className="flex items-center gap-2">
{source && (
<Button
variant="outline"
size="sm"
onClick={pullNow}
disabled={pulling || saving}
>
{pulling ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Pulling</>
) : (
<><RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Pull now</>
)}
</Button>
)}
{canEdit && (
<Button size="sm" onClick={save} disabled={saving}>
{saving ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Saving</>
) : (
<><Save className="w-4 h-4 mr-1.5" strokeWidth={1.5} />{source ? 'Update' : 'Save'}</>
)}
</Button>
)}
</div>
</div>
</Modal>
<GitSourceDiffDialog
open={diffOpen}
onOpenChange={setDiffOpen}
stackName={stackName}
pull={pull}
syncEnv={syncEnv}
autoDeployDefault={applyMode === 'auto-deploy'}
isDarkMode={isDarkMode}
applying={applying}
onApply={applyPull}
onDismiss={dismissPending}
/>
<ConfirmModal
open={removeConfirmOpen}
onOpenChange={setRemoveConfirmOpen}
variant="destructive"
kicker={`${stackName.toUpperCase()} · GIT · DISCONNECT`}
title="Remove Git source"
confirmLabel={deleting ? 'Removing...' : 'Remove'}
confirming={deleting}
onConfirm={remove}
>
<p className="text-sm text-stat-subtitle">
Disconnects the stack from its Git source. The stack files on disk are left in place and you can reconfigure the source later at any time.
</p>
</ConfirmModal>
</>
);
}