From 275c654407d44d6af0dbf4b7a2c8deed3f24695a Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 30 Aug 2026 19:54:19 +0000 Subject: [PATCH] feat(fleet): detect and update from a new sencho-dev:dev build (#1871) * feat(fleet): add self dev-build detection primitives Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns the probe's primary digest alongside the match/update/error verdict) with compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the verdict and the digest no longer has to probe the same mutable tag twice. Add detectSelfDevBuildUpdate, which compares the running container's own image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the new detailed comparison, laying the groundwork for surfacing dev-build updates in Fleet. * feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates Add two pure predicate functions to helpers/selfUpdateCompose.ts for identifying Sencho dev repository references and floating tag variants: - isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev repository, including digest-pinned and dev- tag variants - isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag on the Sencho dev repository (not digest-pinned, not immutable dev-) Both functions reuse existing parsing patterns (normalizeImageRepository for repository extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency. Add comprehensive test coverage in self-update-compose.test.ts covering all specified test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.). * feat(gitops): wire dev-build detection into MonitorService Adds a dev_build_update_available notification category and a new checkSenchoDevBuild() cycle in MonitorService that detects when the running container has fallen behind the rolling ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability state is written unconditionally so the Fleet update affordance never depends on notification delivery succeeding, while a separate dedup key prevents re-notifying for a digest already announced. Also guards checkSenchoVersion() so a dev-repo pin no longer produces a false positive stable-release update notification. * feat(fleet): surface dev-image status and build availability Fleet's GET /update-status now reports isDevImage (any reference to the sencho-dev repository, including digest pins) and devBuildUpdateAvailable (the exact floating :dev tag with a newer build observed, read from the system-state key MonitorService already maintains). A dev-pinned local node forces updateAvailable to false and clears any stale stable-release skip, since that skip was computed before image-pin classification and would otherwise leak a bogus "Skipped" state onto a dev row. Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so both call sites share one string instead of duplicating it. * fix(fleet): omit targetVersion for a dev-image update trigger updateRequestInit() always forwarded latestVersion (the latest stable release) as targetVersion whenever it was valid semver, even for a dev-pinned node. The backend already ignores targetVersion safely for a floating pin, so this never caused an actual repin, but it produced a misleading "Update to X.Y.Z" button label and confirm-dialog copy for an update that installs the dev image, not that stable release. * feat(fleet): add integration-image badge and dev build update button NodeCard now shows a persistent "Integration image" badge whenever a node's compose image is any sencho-dev reference, independent of update availability, visible to every role. When a newer dev build is available, a solid brand-colored "Update dev build" button appears alongside it, admin-only, reusing the existing update trigger and requireAdmin route. Styled distinctly from the neutral stable "Update to X.Y.Z" button so an operator always knows which channel they're acting on. * feat(fleet): add dev-image copy to the local update confirm dialog LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference will be pulled without rewriting the compose image, and that integration images are unsigned and carry no release attestations. Without this, a dev-pinned node's update confirmation fell through to the generic "Pulls Sencho the latest release" copy. FleetView.tsx threads isDevImage from the node's update status through to the dialog, same source as its other pin fields. * feat(fleet): separate dev and stable availability in the Node Updates sheet The sheet counted stable and dev availability together via the same updateAvailable field, so a dev-pinned node with a build available fell into neither the summary counts nor any row action, and would have misleadingly rendered as "Up to date" once devBuildUpdateAvailable existed. stableAvailable and devAvailable are now tracked separately: the changelog dot lights only from stableAvailable (a dev build has no release changelog), the summary and meta text report the combined total, a dev row shows "Integration build" instead of a stable version in the Latest column, and the existing Update button/badge now also fires for devBuildUpdateAvailable. Update all and Skip stay stable-only, since both already gate on fields a dev row never satisfies. * feat(fleet): bring dev-build detection and update to Mobile Fleet Mobile Fleet previously had no update capability at all: it only polled /fleet/overview and never called useFleetUpdateStatus, so it could not show the stable update flow either. It now fetches update status alongside the overview poll, shows the same "integration" marker as desktop on any dev-pinned node's card (visible to every role), and gives admins a dev-build update action. The action renders as a sibling of the card's own button rather than nested inside it, since the card is itself a + + )} + {/* Offline placeholder */} {!isOnline && (
diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index 839fa168..40ef4b60 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -208,8 +208,15 @@ export function NodeUpdatesSheet({ } }; - const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length; - const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; + // Dev availability tracks build freshness by digest, not the stable + // semver compare target, so it is counted separately from stableAvailable. + // A dev row's updateAvailable is always false (fleet.ts), so upToDate must + // exclude it too, or a dev-pinned node with a build available would render + // as "Up to date". + const stableAvailable = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; + const devAvailable = updateStatuses.filter(s => s.devBuildUpdateAvailable && !s.updateStatus).length; + const totalAvailable = stableAvailable + devAvailable; + const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.devBuildUpdateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).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; @@ -222,11 +229,11 @@ export function NodeUpdatesSheet({ const meta = updateStatuses.length === 0 ? 'No nodes' - : `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`; + : `${updateStatuses.length} nodes · ${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`; const footerContext = updateStatuses.length === 0 ? undefined - : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`); + : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`); const secondaryActions = isAdmin && updatableRemoteCount > 0 ? [{ @@ -236,7 +243,9 @@ export function NodeUpdatesSheet({ }] : undefined; - const showChangelogDot = available > 0 && !hasSeenChangelog; + // A dev build has no release changelog entry, so only a stable release + // lights the changelog dot. + const showChangelogDot = stableAvailable > 0 && !hasSeenChangelog; const showSkip = (s: NodeUpdateStatus) => s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion); @@ -347,7 +356,7 @@ export function NodeUpdatesSheet({
-
{available}
+
{totalAvailable}
Available
@@ -405,7 +414,9 @@ export function NodeUpdatesSheet({ {formatVersion(s.version) ?? unknown} - {formatVersion(s.latestVersion) ?? unknown} + {s.isDevImage + ? Integration build + : formatVersion(s.latestVersion) ?? unknown}
{s.updateStatus && ( @@ -421,7 +432,7 @@ export function NodeUpdatesSheet({ onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined} /> )} - {!s.updateStatus && !s.updateAvailable && !s.skipActive && ( + {!s.updateStatus && !s.updateAvailable && !s.devBuildUpdateAvailable && !s.skipActive && ( Up to date @@ -448,7 +459,7 @@ export function NodeUpdatesSheet({ 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 && ( + {(s.updateAvailable || s.devBuildUpdateAvailable) && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && ( @@ -47,6 +48,56 @@ describe('LocalUpdateConfirmDialog', () => { expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument(); }); + it('explains a dev-image update with the dev kicker and no-repin, unsigned-image copy', () => { + render( + , + ); + expect(screen.getByText('LOCAL · DEV UPDATE')).toBeInTheDocument(); + expect(screen.getByText(/ghcr\.io\/studio-saelix\/sencho-dev:dev/)).toBeInTheDocument(); + expect(screen.getByText(/image reference is not rewritten/i)).toBeInTheDocument(); + expect(screen.getByText(/unsigned/i)).toBeInTheDocument(); + }); + + it('keeps the reapply kicker and copy for a dev image in reapply mode', () => { + render( + , + ); + expect(screen.getByText('LOCAL · REAPPLY')).toBeInTheDocument(); + expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument(); + expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument(); + }); + + it('uses the generic update copy and kicker when isDevImage is absent', () => { + render( + , + ); + expect(screen.getByText('LOCAL · UPDATE')).toBeInTheDocument(); + expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument(); + }); + it('explains local reapply without a version change or image rewrite', () => { render( { expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument(); }); + it('shows the Integration image badge regardless of update availability', () => { + render( + , + ); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + }); + + it('does not show the Integration image badge for a non-dev node', () => { + render(); + expect(screen.queryByText('Integration image')).not.toBeInTheDocument(); + }); + + it('shows the dev-build update button for an admin when a dev build is available', async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole('button', { name: /Update dev build/ }); + expect(button).toBeInTheDocument(); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + await user.click(button); + expect(onUpdate).toHaveBeenCalledWith(2); + }); + + it('hides the dev-build update button for a non-admin', () => { + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); + render( + , + ); + expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument(); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + }); + + it('hides the dev-build update button when no dev build is available', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument(); + }); + + it('never shows both update buttons for a well-formed dev row (mutual exclusion by construction)', () => { + // The backend (fleet.ts) guarantees updateAvailable=false whenever isDevImage + // is true, so the two buttons' gating conditions can never both be satisfied + // for real data; the component intentionally adds no redundant isDevImage + // check to the stable button. This fixture reflects what the backend can + // actually send, not an artificial one. + render( + , + ); + expect(screen.getByRole('button', { name: /Update dev build/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Update to/ })).not.toBeInTheDocument(); + }); + it('shows the networking signal badge and switches to the node on click', async () => { const onOpenNetworking = vi.fn(); const user = userEvent.setup(); diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index 04c9654a..522123db 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; const apiFetchMock = vi.fn(); vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) })); @@ -303,6 +303,74 @@ describe('NodeUpdatesSheet', () => { expect(screen.getByLabelText('Retry update')).toBeInTheDocument(); }); + const DEV_STATUSES: NodeUpdateStatus[] = [ + { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true }, + { nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null }, + ]; + + it('counts stable and dev availability separately in the summary and meta text', () => { + render(); + // 2 total available (1 stable + 1 dev). + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('does not light the changelog dot from a dev-only update', () => { + const devOnly: NodeUpdateStatus[] = [ + { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true }, + ]; + render(); + const changelogTab = screen.getByRole('tab', { name: /Changelog/ }); + expect(changelogTab.querySelector('.animate-ping')).toBeNull(); + }); + + it('lights the changelog dot from a stable-only update', () => { + render(); + const changelogTab = screen.getByRole('tab', { name: /Changelog/ }); + expect(changelogTab.querySelector('.animate-ping')).not.toBeNull(); + }); + + it('does not show the per-row Up to date badge for a dev row with a build available', () => { + render(); + const row = screen.getByText('Local').closest('.grid') as HTMLElement; + // The summary section always renders a static "Up to date" category + // label regardless of count, so this must be scoped to the row itself. + expect(within(row).queryByText('Up to date')).not.toBeInTheDocument(); + }); + + it('shows Integration build instead of a stable version in the Latest column for a dev row', () => { + render(); + expect(screen.getByText('Integration build')).toBeInTheDocument(); + }); + + it('shows the Update action for an admin on a dev-available row', () => { + const triggerNodeUpdate = vi.fn(); + render(); + const buttons = screen.getAllByRole('button', { name: /Update$/ }); + // One for the dev row (nodeId 1), one for the stable row (nodeId 2). + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[0]); + expect(triggerNodeUpdate).toHaveBeenCalledWith(1); + }); + + it('shows the read-only Available badge for a non-admin on a dev-available row', () => { + render(); + expect(screen.getAllByText('Available').length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument(); + }); + + it('excludes a dev row from Update all and Skip (both remain stable-only)', () => { + render(); + // Only the remote stable row (nodeId 2) counts toward Update all. + expect(screen.getByRole('button', { name: 'Update all (1)' })).toBeInTheDocument(); + // Skip requires updateAvailable (stable), which is false for the dev row, + // so it never renders one, even though the stable "Edge" row legitimately + // gets one in this same fixture. + const devRow = screen.getByText('Local').closest('.grid') as HTMLElement; + expect(within(devRow).queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument(); + const stableRow = screen.getByText('Edge').closest('.grid') as HTMLElement; + expect(within(stableRow).getByRole('button', { name: 'Skip' })).toBeInTheDocument(); + }); + it('toasts when a recheck is throttled by the server (rechecked:false)', async () => { apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rechecked: false }) }); render(); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx index 60971f18..0d3c5538 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx @@ -248,6 +248,56 @@ describe('useFleetUpdateStatus', () => { vi.unstubAllGlobals(); }); + it('confirmLocalUpdate omits targetVersion for a dev image even when latestVersion is a valid stable version', async () => { + const devStatuses: NodeUpdateStatus[] = [ + { ...STATUSES[0], isDevImage: true }, + STATUSES[1], + ]; + apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + + await act(async () => { await result.current.triggerNodeUpdate(1); }); + expect(result.current.localUpdateConfirm).toBe(1); + + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ))); + + await act(async () => { await result.current.confirmLocalUpdate(); }); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/fleet/nodes/1/update', + expect.objectContaining({ method: 'POST', localOnly: true }), + ); + const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update'); + expect(call![1]).not.toHaveProperty('body'); + vi.unstubAllGlobals(); + }); + + it('confirmLocalUpdate still omits targetVersion for a dev image with no valid latestVersion', async () => { + const devStatuses: NodeUpdateStatus[] = [ + { ...STATUSES[0], isDevImage: true, latestVersion: null }, + STATUSES[1], + ]; + apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + + await act(async () => { await result.current.triggerNodeUpdate(1); }); + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ))); + + await act(async () => { await result.current.confirmLocalUpdate(); }); + + const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update'); + expect(call![1]).not.toHaveProperty('body'); + vi.unstubAllGlobals(); + }); + it('dismisses the reconnecting overlay when the local update resolves failed', async () => { apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES })); const { result } = renderHook(() => useFleetUpdateStatus()); diff --git a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts index a1779a8c..c00fe680 100644 --- a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts +++ b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts @@ -7,10 +7,18 @@ import { useComposeReapplyAction } from './useComposeReapplyAction'; /** POST body for an update trigger: forward the target release when it is a * valid version so the receiving node can repin a semver pin to it; omit - * otherwise so the backend falls back to its compare target. */ + * otherwise so the backend falls back to its compare target. + * + * A dev image never gets a targetVersion, even when latestVersion is a + * valid stable release: latestVersion there is the latest STABLE release, + * unrelated to what a dev-channel update actually installs. The backend + * already ignores targetVersion safely for a floating pin (it only repins + * a semver-classified pin), so this is a copy-accuracy fix, not a safety + * fix: without it, the button label and confirm-dialog would claim a + * stable version number the update isn't installing. */ function updateRequestInit(status: NodeUpdateStatus | undefined): RequestInit & { localOnly: true } { const base = { method: 'POST', localOnly: true } as const; - return isValidVersion(status?.latestVersion) + return !status?.isDevImage && isValidVersion(status?.latestVersion) ? { ...base, body: JSON.stringify({ targetVersion: status!.latestVersion }) } : base; } diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index 6b1ba012..565767de 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -77,6 +77,14 @@ export interface NodeUpdateStatus { operationKind?: 'update' | 'reapply_configuration' | null; /** True when this Compose-managed node can reapply its on-disk configuration. */ canReapplyCompose?: boolean; + /** True when the compose-declared image is any reference to the sencho-dev + * repository, including digest pins and dev-. Reflects what compose + * DECLARES, not necessarily what the container is currently running if + * compose was edited without a reapply. Local node only. */ + isDevImage?: boolean; + /** True only when isDevImage is true, the pin is the exact floating :dev + * tag, and a newer build digest has been observed. Local node only. */ + devBuildUpdateAvailable?: boolean; } export type ViewMode = 'grid' | 'topology'; diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 7df312d4..052780da 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -153,7 +153,8 @@ export function NotificationPanel({ ); const hasNodeUpdateNotifs = useMemo( - () => notifications.some((n) => !n.is_read && n.category === 'node_update_available'), + () => notifications.some((n) => !n.is_read + && (n.category === 'node_update_available' || n.category === 'dev_build_update_available')), [notifications], ); diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index d2485b7d..cd40ed78 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -69,6 +69,7 @@ export type NotificationCategory = | 'health_gate_failed' | 'rollback_generation_released' | 'node_update_available' + | 'dev_build_update_available' | 'system'; export interface NotificationItem { diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index 6eefb642..cb6d8455 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; -import { ChevronRight, Loader2 } from 'lucide-react'; +import { ChevronRight, FlaskConical, Loader2 } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; +import { BusyButton } from '@/components/ui/busy-button'; import { formatBytes } from '@/lib/utils'; import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet'; -import type { FleetNode } from '@/components/FleetView/types'; +import { LocalUpdateConfirmDialog } from '@/components/FleetView/LocalUpdateConfirmDialog'; +import { ReconnectingOverlay } from '@/components/FleetView/ReconnectingOverlay'; +import { useFleetUpdateStatus } from '@/components/FleetView/hooks/useFleetUpdateStatus'; +import type { FleetNode, NodeUpdateStatus } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; @@ -94,7 +98,7 @@ function StatCell({ label, value }: { label: string; value: string }) { ); } -function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) { +function NodeCard({ node, isActive, isDevImage, onOpen }: { node: FleetNode; isActive: boolean; isDevImage: boolean; onOpen: () => void }) { const tone = nodeTone(node); const local = node.type === 'local'; const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online'; @@ -118,6 +122,11 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole active ) : null} + {isDevImage ? ( + + integration + + ) : null} {node.cordoned ? 'cordoned' : stateLabel} @@ -131,6 +140,25 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole ); } +// Sibling to NodeCard's outer