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; triggerNodeUpdate: (nodeId: number) => void; triggerNodeReapply: (nodeId: number) => void; retryNodeUpdate: (nodeId: number) => void; dismissNodeUpdate: (nodeId: number) => void; triggerUpdateAll: () => Promise; } 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(null); const [releaseNotes, setReleaseNotes] = useState(null); const [releaseHtmlUrl, setReleaseHtmlUrl] = useState(null); const [releaseVersion, setReleaseVersion] = useState(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(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 = ( View on Sencho ); return ( { void handleRecheck(); }, disabled: recheckingUpdates || checkingUpdates, } : undefined} secondaryActions={secondaryActions} footerContext={footerContext} tabs={tabs} activeTab={activeTab} onTabChange={(id) => setActiveTab(id as 'nodes' | 'changelog')} size="lg" > {checkingUpdates ? (
Checking for updates...
) : updateStatuses.length === 0 ? (
No nodes found.
) : activeTab === 'changelog' ? (
{loadingRelease ? (
) : releaseNotes ? (
{releaseVersion && (
Release {formatVersion(releaseVersion)}
)} {releaseNotes}
{releaseHtmlUrl && ( View on GitHub )} {senchoChangelogLink}
) : (

No release notes to show

Read the full changelog online.

{senchoChangelogLink}
)}
) : ( <>
{upToDate}
Up to date
{available}
Available
{updating}
Updating
{failed}
Failed
setSearch(e.target.value)} className="h-8 pl-8 text-xs" />
Node Type Current Latest Status
{filtered.map(s => (
{s.type === 'local' ? : }
{s.name}
{s.type} {formatVersion(s.version) ?? unknown} {formatVersion(s.latestVersion) ?? unknown}
{s.updateStatus && ( ( s.operationKind === 'reapply_configuration' ? triggerNodeReapply(s.nodeId) : retryNodeUpdate(s.nodeId) ) : undefined} onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined} /> )} {!s.updateStatus && !s.updateAvailable && !s.skipActive && ( Up to date )} {s.skipActive && ( Skipped {formatVersion(s.skippedVersion)} )} {s.skipActive && isAdmin && ( )} {(s.updateBlocked && s.imageChannel !== 'hardened') && s.updateAvailable && !s.updateStatus && !s.skipActive && ( )} {s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && ( )} {isAdmin && !s.updateStatus && s.canReapplyCompose && ( {updatingNodeId === s.nodeId ? 'Reapplying…' : 'Reapply configuration'} )} {isAdmin && !s.updateStatus && s.canReapplyCompose === false && ( Reapply unavailable )} {showSkip(s) && ( )} {s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && !isAdmin && ( Available )}
))} {filtered.length === 0 && (
No nodes match “{search}”
)}
)}
); }