feat(fleet): reapply Compose configuration without a version update (#1716)

* feat(fleet): reapply Compose configuration without a version update

Add a distinct Fleet Reapply configuration path so Compose-managed nodes can recreate Sencho from the current on-disk project when already up to date, without pulling or rewriting the image reference.

* fix(fleet): confirm remote reapply and close concurrent tracker race

Require confirmation for remote compose reapply, and lock dispatch before the remote POST so a second request cannot overwrite a successful in-flight tracker.

* fix(ui): icon-only Reapply control so Up to date badge can breathe

Collapse the Node updates Reapply label into a tooltip so the status pill no longer wraps in the Status column.

* feat(editor): Save & Reapply self-stack via fleet compose reapply (#1726)

* feat(editor): Save & Reapply self-stack via fleet compose reapply

Eligible admins can apply on-disk Compose edits to Sencho's own stack from the editor using the same confirm, dispatch, and reconnect path as Fleet Node Updates.

* fix(editor): gate Save & Reapply label to self-stack only

Ordinary stacks were labeled Save & Reapply whenever the node was
reapply-eligible. Require the selected file to be the self-stack for the
toolbar label and diff confirm CTA.

* fix(ui): move compose diff action label helper out of dialog module

Keep ComposeDiffPreviewDialog component-only so react-refresh Fast Refresh
lint passes after the Save and reapply stacked merge.
This commit is contained in:
Anso
2026-07-28 14:26:46 -04:00
committed by GitHub
parent 543e4ef256
commit b0b423b234
42 changed files with 1770 additions and 168 deletions
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { MarkdownContent } from '@/components/ui/MarkdownContent';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion, isValidVersion } from '@/lib/version';
@@ -29,6 +30,7 @@ interface NodeUpdatesSheetProps {
initialTab?: 'nodes' | 'changelog';
fetchUpdateStatus: () => Promise<void>;
triggerNodeUpdate: (nodeId: number) => void;
triggerNodeReapply: (nodeId: number) => void;
retryNodeUpdate: (nodeId: number) => void;
dismissNodeUpdate: (nodeId: number) => void;
triggerUpdateAll: () => Promise<void>;
@@ -37,7 +39,7 @@ interface NodeUpdatesSheetProps {
export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
initialTab = 'nodes',
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
fetchUpdateStatus, triggerNodeUpdate, triggerNodeReapply, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
}: NodeUpdatesSheetProps) {
const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
@@ -410,12 +412,17 @@ export function NodeUpdatesSheet({
<UpdateStatusBadge
status={s.updateStatus}
error={s.error}
onRetry={isAdmin ? () => retryNodeUpdate(s.nodeId) : undefined}
operationKind={s.operationKind}
onRetry={isAdmin ? () => (
s.operationKind === 'reapply_configuration'
? triggerNodeReapply(s.nodeId)
: retryNodeUpdate(s.nodeId)
) : undefined}
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
/>
)}
{!s.updateStatus && !s.updateAvailable && !s.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
<Badge className="text-[10px] px-1.5 py-0 h-5 shrink-0 whitespace-nowrap bg-success-muted text-success border-success/30">
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
</Badge>
)}
@@ -456,6 +463,41 @@ export function NodeUpdatesSheet({
)}
</Button>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 text-muted-foreground hover:text-stat-value"
onClick={() => triggerNodeReapply(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
aria-label={updatingNodeId === s.nodeId ? 'Reapplying configuration' : 'Reapply configuration'}
>
{updatingNodeId === s.nodeId ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{updatingNodeId === s.nodeId ? 'Reapplying…' : 'Reapply configuration'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose === false && (
<span
className="text-[10px] text-muted-foreground/70 max-w-[9rem] text-right leading-tight"
title={s.type === 'local'
? 'This node is not Compose-managed, so configuration reapply is unavailable.'
: 'This node does not advertise Compose self-management, or is unreachable.'}
>
Reapply unavailable
</span>
)}
{showSkip(s) && (
<Button
variant="ghost"