Files
sencho/frontend/src/components/FleetView/NodeUpdatesSheet.tsx
T
Anso b0b423b234 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.
2026-07-28 14:26:46 -04:00

532 lines
30 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
Download, RefreshCw, Monitor, Globe, ExternalLink, Ban, ScrollText,
} from 'lucide-react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Input } from '@/components/ui/input';
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';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
import type { NodeUpdateStatus } from './types';
interface NodeUpdatesSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
checkingUpdates: boolean;
updateStatuses: NodeUpdateStatus[];
updatingNodeId: number | null;
/** Mutating affordances (update, update-all, retry, dismiss, recheck) render
* only for admins, matching the requireAdmin guard on the fleet routes they
* call. Non-admins still see the read-only status table. */
isAdmin: boolean;
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>;
}
export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
initialTab = 'nodes',
fetchUpdateStatus, triggerNodeUpdate, triggerNodeReapply, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
}: NodeUpdatesSheetProps) {
const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
const [activeTab, setActiveTab] = useState<'nodes' | 'changelog'>(initialTab);
const [skipLoading, setSkipLoading] = useState<number | null>(null);
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const [releaseHtmlUrl, setReleaseHtmlUrl] = useState<string | null>(null);
const [releaseVersion, setReleaseVersion] = useState<string | null>(null);
const [loadingRelease, setLoadingRelease] = useState(false);
// The advertised latest version the loaded notes were fetched against, or
// `undefined` before the first settle (so a null/unknown advertised version
// still triggers the initial fetch). When it no longer matches the advertised
// latest, the notes are refetched so the changelog never shows a previous
// version's notes; settling on any result (including null) records the version
// so a failed fetch lands on the empty state instead of looping.
const [loadedForVersion, setLoadedForVersion] = useState<string | null | undefined>(undefined);
const [hasSeenChangelog, setHasSeenChangelog] = useState(false);
// The version the Fleet currently advertises as latest (gateway-derived, same
// as the footer's "Latest version" label). Notes are keyed to this so they
// stay in sync.
const advertisedLatest = (updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0])?.latestVersion ?? null;
useEffect(() => {
if (open) setActiveTab(initialTab);
}, [open, initialTab]);
// Fetch release notes when the sheet opens (changelog shows the current
// release regardless of update availability), and refetch whenever the
// advertised latest version changes so reopening after a newer release
// surfaces its notes, never a previous version's. Pass recheck when the user
// forced a version recheck so the changelog stays in sync.
useEffect(() => {
if (!open || loadingRelease) return;
if (loadedForVersion === advertisedLatest) return;
// Drop any previously loaded notes before fetching for a (possibly) newer
// advertised version. If this fetch mismatches, returns null, errors, or
// rejects, the panel must fall to the empty state rather than keep showing
// the prior version's notes; a confirmed match repopulates below. The
// skeleton (loadingRelease) covers the in-flight gap, so this does not flash.
setReleaseNotes(null);
setReleaseHtmlUrl(null);
setReleaseVersion(null);
setLoadingRelease(true);
const recheck = recheckingUpdates ? '?recheck=true' : '';
apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true })
.then(res => res.ok ? res.json() as Promise<{ version: string | null; releaseNotes: string | null; htmlUrl: string | null }> : null)
.then(data => {
// Bind strictly to the advertised update: render only notes the
// endpoint confirms belong to the advertised version. The version
// lookup and the release-notes lookup use independent caches (and
// version can fall back to Docker Hub while notes are GitHub-only),
// so a drifted, null, or failed response keeps the cleared state set
// above and falls through to the empty state with the online link.
if (data && data.version !== null && data.version === advertisedLatest) {
setReleaseNotes(data.releaseNotes);
setReleaseHtmlUrl(data.htmlUrl);
setReleaseVersion(data.version);
}
})
.catch((err) => {
// Informational panel: a failure falls through to the empty state
// (notes already cleared above) rather than a toast, but leave a
// breadcrumb so the failure is diagnosable.
console.warn('[Fleet] Release-notes fetch failed:', err);
})
.finally(() => {
setLoadingRelease(false);
// Record the advertised version this fetch settled for, so a null
// or failed result does not loop and a later version change forces
// a refetch.
setLoadedForVersion(advertisedLatest);
});
}, [open, advertisedLatest, loadedForVersion, loadingRelease, recheckingUpdates]);
// Clear the changelog dot when user opens that tab.
useEffect(() => {
if (open && activeTab === 'changelog') {
setHasSeenChangelog(true);
}
}, [open, activeTab]);
const handleOpenChange = (next: boolean) => {
onOpenChange(next);
if (!next) {
setSearch('');
setActiveTab('nodes');
setHasSeenChangelog(false);
}
};
const handleRecheck = async () => {
setRecheckingUpdates(true);
// Force a fresh release-notes fetch with the rechecked version: clearing
// loadedForVersion makes the effect's version guard miss and refetch.
setReleaseNotes(null);
setReleaseHtmlUrl(null);
setReleaseVersion(null);
setLoadedForVersion(undefined);
try {
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
if (res.ok) {
// The server throttles the upstream version lookup; `rechecked:false`
// means a forced refresh ran too recently and the cached value stands.
const data = await res.json().catch(() => ({}));
if (data?.rechecked === false) {
toast.info('Already checked for the latest version recently.');
}
} else {
// apiFetch only throws on 401/network, so HTTP errors (e.g. a 500
// from the upstream lookup) land here, not in the catch below.
console.warn('[Fleet] Recheck returned HTTP', res.status);
toast.error('Could not recheck for updates. Try again shortly.');
}
await fetchUpdateStatus();
} catch (err) {
// Recheck is an explicit user click, so a thrown network/auth failure
// gets a toast, not just a console breadcrumb.
console.warn('[Fleet] Recheck failed:', err);
toast.error('Could not recheck for updates. Try again shortly.');
} finally {
setRecheckingUpdates(false);
}
};
const handleSkipVersion = async (nodeId: number, version: string | null) => {
if (!version) return;
setSkipLoading(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
method: 'POST',
body: JSON.stringify({ version }),
localOnly: true,
});
if (res.ok || res.status === 204) {
toast.success('Version skipped.');
await fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to skip version.');
}
} catch {
toast.error('Failed to skip version.');
} finally {
setSkipLoading(null);
}
};
const handleUnskipVersion = async (nodeId: number) => {
setSkipLoading(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
method: 'DELETE',
localOnly: true,
});
if (res.ok || res.status === 204) {
toast.success('Skip cleared.');
await fetchUpdateStatus();
} else {
toast.error('Failed to clear skip.');
}
} catch {
toast.error('Failed to clear skip.');
} finally {
setSkipLoading(null);
}
};
const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length;
const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length;
const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length;
const updatableRemoteCount = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length;
const q = search.toLowerCase();
const filtered = q
? updateStatuses.filter(s => s.name.toLowerCase().includes(q) || s.type.includes(q))
: updateStatuses;
const localEntry = updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0];
const gatewayLabel = formatVersion(localEntry?.latestVersion);
const meta = updateStatuses.length === 0
? 'No nodes'
: `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`;
const footerContext = updateStatuses.length === 0
? undefined
: (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
const secondaryActions = isAdmin && updatableRemoteCount > 0
? [{
label: `Update all (${updatableRemoteCount})`,
icon: Download,
onClick: () => { void triggerUpdateAll(); },
}]
: undefined;
const showChangelogDot = available > 0 && !hasSeenChangelog;
const showSkip = (s: NodeUpdateStatus) =>
s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion);
const tabs: Array<{ id: string; label: string; count?: number; dot?: boolean }> = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'changelog', label: 'Changelog', dot: showChangelogDot },
];
const senchoChangelogLink = (
<a
href="https://sencho.io/changelog"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
>
View on Sencho <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
);
return (
<SystemSheet
open={open}
onOpenChange={handleOpenChange}
crumb={['Fleet', 'Updates']}
name="Node updates"
meta={meta}
primaryAction={isAdmin ? {
label: 'Recheck',
icon: recheckingUpdates ? Loader2 : RefreshCw,
onClick: () => { void handleRecheck(); },
disabled: recheckingUpdates || checkingUpdates,
} : undefined}
secondaryActions={secondaryActions}
footerContext={footerContext}
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as 'nodes' | 'changelog')}
size="lg"
>
{checkingUpdates ? (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for updates...
</div>
) : updateStatuses.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
No nodes found.
</div>
) : activeTab === 'changelog' ? (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
{loadingRelease ? (
<div className="space-y-3">
<Skeleton className="h-5 w-2/5" />
<Skeleton className="h-3 w-1/4" />
<div className="space-y-2 pt-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-11/12" />
<Skeleton className="h-3 w-4/5" />
</div>
<div className="space-y-2 pt-3">
<Skeleton className="h-3 w-1/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
) : releaseNotes ? (
<div className="space-y-4">
{releaseVersion && (
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle">
Release {formatVersion(releaseVersion)}
</div>
)}
<MarkdownContent>{releaseNotes}</MarkdownContent>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-card-border/40 pt-3">
{releaseHtmlUrl && (
<a
href={releaseHtmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
>
View on GitHub <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
)}
{senchoChangelogLink}
</div>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
<ScrollText className="w-8 h-8 text-muted-foreground/40" strokeWidth={1.25} />
<div className="space-y-1">
<p className="text-sm text-stat-value">No release notes to show</p>
<p className="text-xs text-muted-foreground">Read the full changelog online.</p>
</div>
{senchoChangelogLink}
</div>
)}
</div>
) : (
<>
<SheetSection title="Summary">
<div className="grid grid-cols-4 gap-x-4 divide-x divide-card-border/40 text-center">
<div className="px-2">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
</div>
</div>
<div className="px-2">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
</div>
</div>
<div className="px-2">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<Loader2 className="w-3 h-3 text-brand" strokeWidth={1.5} /> Updating
</div>
</div>
<div className="px-2">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
</div>
</div>
</div>
</SheetSection>
<SheetSection title={`Nodes · ${updateStatuses.length}`}>
<div className="relative mb-3">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter nodes..."
value={search}
onChange={e => setSearch(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
<span>Node</span>
<span>Type</span>
<span>Current</span>
<span>Latest</span>
<span className="text-right">Status</span>
</div>
<div className="divide-y divide-card-border/40">
{filtered.map(s => (
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 items-center px-3 py-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
{s.type === 'local'
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
}
</div>
<span className="text-sm font-medium truncate">{s.name}</span>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
{s.type}
</Badge>
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
<span className="text-xs font-mono tabular-nums">
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
<div className="flex justify-end items-center gap-1">
{s.updateStatus && (
<UpdateStatusBadge
status={s.updateStatus}
error={s.error}
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 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>
)}
{s.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40">
<Ban className="w-2.5 h-2.5 mr-0.5" /> Skipped {formatVersion(s.skippedVersion)}
</Badge>
)}
{s.skipActive && isAdmin && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-stat-value"
onClick={() => { void handleUnskipVersion(s.nodeId); }}
disabled={skipLoading === s.nodeId}
>
Unskip
</Button>
)}
{(s.updateBlocked && s.imageChannel !== 'hardened') && s.updateAvailable && !s.updateStatus && !s.skipActive && (
<PinnedUpdateBadge
reason={s.updateBlockedReason}
className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40"
/>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && (
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2.5"
onClick={() => triggerNodeUpdate(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
>
{updatingNodeId === s.nodeId ? (
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
) : (
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
)}
</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"
size="sm"
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-warning"
onClick={() => { void handleSkipVersion(s.nodeId, s.latestVersion); }}
disabled={skipLoading === s.nodeId}
>
Skip
</Button>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && !isAdmin && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
</Badge>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
No nodes match &ldquo;{search}&rdquo;
</div>
)}
</div>
</SheetSection>
</>
)}
</SystemSheet>
);
}