mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +00:00
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-<sha> tag variants - isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag on the Sencho dev repository (not digest-pinned, not immutable dev-<sha>) 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 <button> and a nested button is invalid HTML with broken touch semantics. It reuses the exact same triggerNodeUpdate/confirmLocalUpdate flow and LocalUpdateConfirmDialog /ReconnectingOverlay components desktop already renders, so there is no parallel API implementation to keep in sync. * feat(notifications): wire dev_build_update_available through the frontend Adds the category to the frontend NotificationCategory union, its bell label, the per-node "mute update notifications" bundle, and the bell's friendly dot-color memo. The changelog navigation and "View changelog" button stay scoped to node_update_available only: a dev build has no release changelog entry to navigate to. * docs: document dev-build detection and update on Fleet Adds the dev_build_update_available notification category, the persistent Integration image marker, and the dev-build update action (desktop and mobile) to the alerts-notifications, verifying-images, fleet-view, remote-updates, and upgrade pages. States the detection cadence explicitly: it polls on a fixed interval and reflects the newest build observed, not necessarily every individual build. * fix(gitops): sanitize the inconclusive-reason debug log for log injection CodeQL flagged the dev-build check's debug log as depending on a user-influenced value (a registry probe failure reason can trace back to external input). Wraps it with sanitizeForLog(), the existing repo-wide remediation for this class of finding, matching how registry-api.ts already handles the same pattern. * test(gitops): cover the no-repin invariant on a dev-build self-update Proves triggerUpdate(), called with neither targetVersion nor targetImageRef (the exact dev-build update call), pulls the current compose-declared ref unchanged and never stages a compose rewrite. * fix(fleet): use the shared busy-button pattern on Mobile Fleet's dev update action Replaces the local Loader2 plus boolean pending logic with BusyButton so busy behavior and interaction locking stay in sync with the rest of the app's async click surfaces. * test(gitops): exercise the production call shape in the no-repin regression Fleet substitutes the stable compare target when the request body omits one, so SelfUpdateService receives a targetVersion even for a dev-build update. The guard that protects a :dev install is therefore the semver check inside the repin branch, not the absence of a target. Drives triggerUpdate with a forwarded target against a floating :dev pin and asserts the reference is pulled unchanged with no staged patch, and pairs it with a semver case so the negative assertions cannot pass vacuously.
This commit is contained in:
@@ -400,6 +400,7 @@ export function FleetView({
|
||||
composeImageRef={confirmStatus?.composeImageRef}
|
||||
targetImageRef={confirmStatus?.targetImageRef}
|
||||
targetVersion={confirmStatus?.latestVersion}
|
||||
isDevImage={confirmStatus?.isDevImage}
|
||||
/>
|
||||
|
||||
{NodeActionModals}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { Download, FlaskConical, RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
@@ -15,19 +15,24 @@ interface LocalUpdateConfirmDialogProps {
|
||||
composeImageRef?: string | null;
|
||||
targetImageRef?: string | null;
|
||||
targetVersion?: string | null;
|
||||
/** True when the node's compose image is any sencho-dev reference. Only
|
||||
* meaningful in update mode; ignored for reapply. */
|
||||
isDevImage?: boolean;
|
||||
}
|
||||
|
||||
export function LocalUpdateConfirmDialog({
|
||||
open, onOpenChange, onConfirm, mode = 'update', nodeType = 'local',
|
||||
imagePinKind, composeImageRef, targetImageRef, targetVersion,
|
||||
imagePinKind, composeImageRef, targetImageRef, targetVersion, isDevImage,
|
||||
}: LocalUpdateConfirmDialogProps) {
|
||||
const isReapply = mode === 'reapply';
|
||||
const isRemoteReapply = isReapply && nodeType === 'remote';
|
||||
const isDevUpdate = !isReapply && isDevImage;
|
||||
const versionLabel = formatVersion(targetVersion) ?? 'the latest release';
|
||||
|
||||
let kicker = 'LOCAL · UPDATE';
|
||||
if (isRemoteReapply) kicker = 'REMOTE · REAPPLY';
|
||||
else if (isReapply) kicker = 'LOCAL · REAPPLY';
|
||||
else if (isDevUpdate) kicker = 'LOCAL · DEV UPDATE';
|
||||
|
||||
let body: ReactNode;
|
||||
if (isRemoteReapply) {
|
||||
@@ -47,6 +52,15 @@ export function LocalUpdateConfirmDialog({
|
||||
reconnects automatically when the restart completes.
|
||||
</p>
|
||||
);
|
||||
} else if (isDevUpdate) {
|
||||
body = (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Pulls <code className="text-stat-value">ghcr.io/studio-saelix/sencho-dev:dev</code> and
|
||||
restarts the server. The image reference is not rewritten. Integration
|
||||
images are unsigned and carry no release attestations. The dashboard
|
||||
briefly disconnects and reconnects automatically when the update completes.
|
||||
</p>
|
||||
);
|
||||
} else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) {
|
||||
body = (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
@@ -74,6 +88,11 @@ export function LocalUpdateConfirmDialog({
|
||||
<RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Reapply & restart
|
||||
</>
|
||||
) : isDevUpdate ? (
|
||||
<>
|
||||
<FlaskConical className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Update & restart
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2,
|
||||
MoreVertical, Ban, Pencil, Trash2, Info,
|
||||
MoreVertical, Ban, Pencil, Trash2, Info, FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -257,6 +257,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
Skipped
|
||||
</Badge>
|
||||
)}
|
||||
{updateStatus?.isDevImage && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
||||
<FlaskConical className="w-2.5 h-2.5 mr-0.5" strokeWidth={1.5} /> Integration image
|
||||
</Badge>
|
||||
)}
|
||||
{isOnline && isCritical(node) && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
|
||||
@@ -359,6 +364,27 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dev-build update button: mutating action, admin only, same requireAdmin
|
||||
route as the stable update above. No skipActive term: the backend
|
||||
already clears skipActive for a dev row (fleet.ts), so adding it here
|
||||
would reintroduce that stale-skip leak. */}
|
||||
{isOnline && updateStatus?.devBuildUpdateAvailable && !updateStatus.updateStatus && onUpdate && isAdmin && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs bg-brand text-brand-foreground hover:bg-brand/90 border-0"
|
||||
onClick={() => onUpdate(node.id)}
|
||||
disabled={updatingNodeId === node.id}
|
||||
>
|
||||
{updatingNodeId === node.id ? (
|
||||
<><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Triggering...</>
|
||||
) : (
|
||||
<><FlaskConical className="w-3 h-3 mr-1.5" strokeWidth={1.5} />Update dev build</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offline placeholder */}
|
||||
{!isOnline && (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground text-sm">
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{totalAvailable}</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>
|
||||
@@ -405,7 +414,9 @@ export function NodeUpdatesSheet({
|
||||
{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>}
|
||||
{s.isDevImage
|
||||
? <span className="text-muted-foreground/70 italic text-[10px]">Integration build</span>
|
||||
: 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 && (
|
||||
@@ -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 && (
|
||||
<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>
|
||||
@@ -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 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -509,7 +520,7 @@ export function NodeUpdatesSheet({
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/ui/modal', () => ({
|
||||
ConfirmModal: ({ open, title, children, confirmLabel }: {
|
||||
open: boolean; title: string; children: React.ReactNode; confirmLabel: React.ReactNode;
|
||||
ConfirmModal: ({ open, title, children, confirmLabel, kicker }: {
|
||||
open: boolean; title: string; children: React.ReactNode; confirmLabel: React.ReactNode; kicker: string;
|
||||
}) => open ? (
|
||||
<div>
|
||||
<span>{kicker}</span>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
<button type="button">{confirmLabel}</button>
|
||||
@@ -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(
|
||||
<LocalUpdateConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
isDevImage
|
||||
imagePinKind="floating"
|
||||
composeImageRef="ghcr.io/studio-saelix/sencho-dev:dev"
|
||||
targetVersion="0.99.0"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LocalUpdateConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
mode="reapply"
|
||||
nodeType="local"
|
||||
isDevImage
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LocalUpdateConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
imagePinKind="semver"
|
||||
composeImageRef="saelix/sencho:0.93.3"
|
||||
targetImageRef="saelix/sencho:0.94.0"
|
||||
targetVersion="0.94.0"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LocalUpdateConfirmDialog
|
||||
|
||||
@@ -142,6 +142,79 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Integration image badge regardless of update availability', () => {
|
||||
render(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateAvailable: false, isDevImage: true, devBuildUpdateAvailable: false }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Integration image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the Integration image badge for a non-dev node', () => {
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
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(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateAvailable: false, isDevImage: true, devBuildUpdateAvailable: true }}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateAvailable: false, isDevImage: true, devBuildUpdateAvailable: true }}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateAvailable: false, isDevImage: true, devBuildUpdateAvailable: false }}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateAvailable: false, isDevImage: true, devBuildUpdateAvailable: true }}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES })} />);
|
||||
// 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(<NodeUpdatesSheet {...baseProps({ updateStatuses: devOnly })} />);
|
||||
const changelogTab = screen.getByRole('tab', { name: /Changelog/ });
|
||||
expect(changelogTab.querySelector('.animate-ping')).toBeNull();
|
||||
});
|
||||
|
||||
it('lights the changelog dot from a stable-only update', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps()} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES })} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES })} />);
|
||||
expect(screen.getByText('Integration build')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Update action for an admin on a dev-available row', () => {
|
||||
const triggerNodeUpdate = vi.fn();
|
||||
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES, triggerNodeUpdate })} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES, isAdmin: false })} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ updateStatuses: DEV_STATUSES })} />);
|
||||
// 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(<NodeUpdatesSheet {...baseProps({ isAdmin: true })} />);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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-<sha>. 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';
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export type NotificationCategory =
|
||||
| 'health_gate_failed'
|
||||
| 'rollback_generation_released'
|
||||
| 'node_update_available'
|
||||
| 'dev_build_update_available'
|
||||
| 'system';
|
||||
|
||||
export interface NotificationItem {
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
) : null}
|
||||
{isDevImage ? (
|
||||
<span className="flex items-center gap-1 rounded-[5px] bg-warning/[0.12] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-warning">
|
||||
<FlaskConical className="h-2.5 w-2.5" strokeWidth={1.5} /> integration
|
||||
</span>
|
||||
) : null}
|
||||
<Kicker className={tone === 'destructive' ? 'text-destructive' : tone === 'warning' ? 'text-warning' : 'text-stat-subtitle'}>
|
||||
{node.cordoned ? 'cordoned' : stateLabel}
|
||||
</Kicker>
|
||||
@@ -131,6 +140,25 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole
|
||||
);
|
||||
}
|
||||
|
||||
// Sibling to NodeCard's outer <button>, never nested inside it (a nested
|
||||
// <button> is invalid HTML and breaks touch semantics). Only ever rendered
|
||||
// for a local, admin, dev-build-available node.
|
||||
function DevBuildUpdateAction({ nodeId, onUpdate, updating }: { nodeId: number; onUpdate: (nodeId: number) => void; updating: boolean }) {
|
||||
return (
|
||||
<BusyButton
|
||||
pending={updating}
|
||||
busyLabel="Triggering..."
|
||||
onClick={() => onUpdate(nodeId)}
|
||||
// ghost has no background/shadow of its own, so the brand classes below
|
||||
// are the only visual styling; the hover: overrides null out ghost's hover tint.
|
||||
variant="ghost"
|
||||
className="min-h-11 w-full rounded-[12px] bg-brand font-mono text-[12px] uppercase tracking-[0.14em] text-brand-foreground hover:bg-brand hover:text-brand-foreground disabled:opacity-60"
|
||||
>
|
||||
<FlaskConical strokeWidth={1.5} /> Update dev build
|
||||
</BusyButton>
|
||||
);
|
||||
}
|
||||
|
||||
// One labeled resource bar in the node detail.
|
||||
function ResourceRow({ label, pct, detail }: { label: string; pct: number; detail: string }) {
|
||||
return (
|
||||
@@ -291,6 +319,8 @@ function NodeDetail({
|
||||
export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: MobileFleetProps) {
|
||||
const { nodes, loading, lastSyncAt, refetch } = useMobileFleet();
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -298,17 +328,63 @@ export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: Mo
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Mirrors useMobileFleet's own polling style above; mobile has no
|
||||
// reapply trigger, so only the update-trigger/dev-build path needs
|
||||
// this data.
|
||||
void updateStatus.fetchUpdateStatus();
|
||||
const id = setInterval(() => void updateStatus.fetchUpdateStatus(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
// Depend on the memoized fetchUpdateStatus callback alone, not the whole
|
||||
// updateStatus object (a new reference every render): per this repo's
|
||||
// React dependency-trap rule, adding the object would re-run this effect
|
||||
// on every render and thrash the interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateStatus.fetchUpdateStatus]);
|
||||
|
||||
const updateStatusByNodeId = new Map<number, NodeUpdateStatus>(
|
||||
updateStatus.updateStatuses.map(s => [s.nodeId, s]),
|
||||
);
|
||||
|
||||
const confirmStatus = updateStatus.localUpdateConfirm !== null
|
||||
? updateStatusByNodeId.get(updateStatus.localUpdateConfirm)
|
||||
: undefined;
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
{updateStatus.reconnecting && (
|
||||
<ReconnectingOverlay
|
||||
preUpdateStartedAt={updateStatus.preUpdateStartedAt}
|
||||
mode={updateStatus.reconnectMode}
|
||||
/>
|
||||
)}
|
||||
<LocalUpdateConfirmDialog
|
||||
open={updateStatus.localUpdateConfirm !== null}
|
||||
onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }}
|
||||
onConfirm={updateStatus.confirmLocalUpdate}
|
||||
imagePinKind={confirmStatus?.imagePinKind}
|
||||
composeImageRef={confirmStatus?.composeImageRef}
|
||||
targetImageRef={confirmStatus?.targetImageRef}
|
||||
targetVersion={confirmStatus?.latestVersion}
|
||||
isDevImage={confirmStatus?.isDevImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const selected = selectedId !== null ? nodes.find(n => n.id === selectedId) ?? null : null;
|
||||
if (selected) {
|
||||
return (
|
||||
<NodeDetail
|
||||
node={selected}
|
||||
now={now}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onInspectNode={onInspectNode}
|
||||
onInspectStack={onInspectStack}
|
||||
onCordonChange={() => void refetch()}
|
||||
/>
|
||||
<>
|
||||
<NodeDetail
|
||||
node={selected}
|
||||
now={now}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onInspectNode={onInspectNode}
|
||||
onInspectStack={onInspectStack}
|
||||
onCordonChange={() => void refetch()}
|
||||
/>
|
||||
{overlays}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,42 +404,57 @@ export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: Mo
|
||||
const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker="fleet · overview"
|
||||
state={label}
|
||||
stateTone={tone}
|
||||
live={level !== 'healthy'}
|
||||
meta={`${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'} · ${totalStacks} stacks · ${syncLabel}`}
|
||||
right={headerActions}
|
||||
/>
|
||||
<>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker="fleet · overview"
|
||||
state={label}
|
||||
stateTone={tone}
|
||||
live={level !== 'healthy'}
|
||||
meta={`${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'} · ${totalStacks} stacks · ${syncLabel}`}
|
||||
right={headerActions}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
|
||||
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<StatCell label="running" value={`${running}`} />
|
||||
<StatCell label="cpu" value={onlineNodes.length > 0 ? `${avgCpu.toFixed(0)}%` : '--'} />
|
||||
<StatCell label="mem" value={memTotal > 0 ? `${memPct.toFixed(0)}%` : '--'} />
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
|
||||
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<StatCell label="running" value={`${running}`} />
|
||||
<StatCell label="cpu" value={onlineNodes.length > 0 ? `${avgCpu.toFixed(0)}%` : '--'} />
|
||||
<StatCell label="mem" value={memTotal > 0 ? `${memPct.toFixed(0)}%` : '--'} />
|
||||
</div>
|
||||
|
||||
{loading && nodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No nodes configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{nodes.map(node => {
|
||||
const nodeUpdateStatus = updateStatusByNodeId.get(node.id);
|
||||
return (
|
||||
<div key={node.id} className="flex flex-col gap-2.5">
|
||||
<NodeCard
|
||||
node={node}
|
||||
isActive={activeNode?.id === node.id}
|
||||
isDevImage={Boolean(nodeUpdateStatus?.isDevImage)}
|
||||
onOpen={() => setSelectedId(node.id)}
|
||||
/>
|
||||
{nodeUpdateStatus?.devBuildUpdateAvailable && isAdmin && (
|
||||
<DevBuildUpdateAction
|
||||
nodeId={node.id}
|
||||
onUpdate={updateStatus.triggerNodeUpdate}
|
||||
updating={updateStatus.updatingNodeId === node.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && nodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No nodes configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{nodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
isActive={activeNode?.id === node.id}
|
||||
onOpen={() => setSelectedId(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{overlays}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Mobile Fleet has no update capability at all before this change (it only
|
||||
* polled /fleet/overview). These tests confirm it now shows the persistent
|
||||
* "Integration image" marker for any role, exposes the dev-build update
|
||||
* action only to admins as a sibling of the card's own <button> (never
|
||||
* nested inside it), and routes through the same shared confirm dialog and
|
||||
* update trigger desktop uses (no parallel API implementation).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { FleetNode, NodeUpdateStatus } from '@/components/FleetView/types';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
const useAuthMock = vi.fn();
|
||||
const useNodesMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args) }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
||||
vi.mock('@/lib/nodesApi', () => ({ cordonNode: vi.fn(), uncordonNode: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { MobileFleet } from '../MobileFleet';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function makeNode(overrides: Partial<FleetNode> = {}): FleetNode {
|
||||
return {
|
||||
id: 1, name: 'Local', type: 'local', status: 'online',
|
||||
stats: { active: 1, managed: 1, unmanaged: 0, exited: 0, total: 1 },
|
||||
systemStats: { cpu: { usage: '10.0', cores: 4 }, memory: { total: 100, used: 20, free: 80, usagePercent: '20.0' }, disk: { total: 100, used: 10, free: 90, usagePercent: '10.0' } },
|
||||
stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null,
|
||||
...overrides,
|
||||
} as FleetNode;
|
||||
}
|
||||
|
||||
function makeUpdateStatus(overrides: Partial<NodeUpdateStatus> = {}): NodeUpdateStatus {
|
||||
return {
|
||||
nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0',
|
||||
updateAvailable: false, updateStatus: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupFetch(nodes: FleetNode[], statuses: NodeUpdateStatus[]) {
|
||||
apiFetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/fleet/overview') return okJson(nodes);
|
||||
if (url === '/fleet/update-status') return okJson({ nodes: statuses });
|
||||
if (url.startsWith('/fleet/nodes/')) return okJson({ message: 'ok' });
|
||||
return okJson({});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
useNodesMock.mockReturnValue({ nodes: [], activeNode: null, hasCapability: vi.fn(() => false) });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('MobileFleet dev-build capability', () => {
|
||||
it('shows the Integration image marker for a viewer (non-admin)', async () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: false })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/integration/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Update dev build/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the marker for a non-dev node', async () => {
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: false })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Local');
|
||||
expect(screen.queryByText(/integration/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the dev-build update action as a sibling of the card, not nested inside its button, for an admin', async () => {
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: true })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
const updateButton = await screen.findByRole('button', { name: /Update dev build/i });
|
||||
const cardButton = screen.getByRole('button', { name: /Local/i });
|
||||
expect(updateButton).not.toBe(cardButton);
|
||||
// A <button> cannot legally contain another <button>; assert the update
|
||||
// action is not a DOM descendant of the card's own button.
|
||||
expect(cardButton.contains(updateButton)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the dev-build update action for a non-admin while keeping the marker', async () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: true })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
await screen.findByText(/integration/i);
|
||||
expect(screen.queryByRole('button', { name: /Update dev build/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the dev-build update action when no build is available', async () => {
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: false })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
await screen.findByText(/integration/i);
|
||||
expect(screen.queryByRole('button', { name: /Update dev build/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tapping the update action opens the shared confirm dialog with dev copy', async () => {
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: true })]);
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
|
||||
const updateButton = await screen.findByRole('button', { name: /Update dev build/i });
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
expect(await screen.findByText('LOCAL · DEV UPDATE')).toBeInTheDocument();
|
||||
expect(screen.getByText(/image reference is not rewritten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('confirming the dialog triggers the update with targetVersion omitted and shows the reconnect overlay', async () => {
|
||||
setupFetch([makeNode()], [makeUpdateStatus({ isDevImage: true, devBuildUpdateAvailable: true, latestVersion: '9.9.9' })]);
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
|
||||
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)));
|
||||
|
||||
render(<MobileFleet headerActions={null} onInspectNode={vi.fn()} onInspectStack={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Update dev build/i }));
|
||||
await screen.findByText('LOCAL · DEV UPDATE');
|
||||
|
||||
apiFetchMock.mockClear();
|
||||
apiFetchMock.mockImplementation(async () => okJson({ message: 'ok' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Update & restart/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
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');
|
||||
expect(await screen.findByText(/restarting/i)).toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,13 @@ describe('notificationVisibility', () => {
|
||||
expect(countVisibleUnread([monitor, update])).toBe(2);
|
||||
});
|
||||
|
||||
it('shows unread dev_build_update_available', () => {
|
||||
const n = notif({ category: 'dev_build_update_available' });
|
||||
expect(isPanelHiddenNotification(n)).toBe(false);
|
||||
expect(isVisibleUnread(n)).toBe(true);
|
||||
expect(countVisibleUnread([n])).toBe(1);
|
||||
});
|
||||
|
||||
it('shows scheduler image_update_applied (system actor, not human)', () => {
|
||||
const n = notif({ category: 'image_update_applied', actor_username: 'system:scheduler' });
|
||||
expect(isPanelHiddenNotification(n)).toBe(false);
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { createMuteRule, stackMuteAllDraft } from './muteRules';
|
||||
import { createMuteRule, stackMuteAllDraft, nodeMuteUpdatesDraft } from './muteRules';
|
||||
|
||||
describe('muteRules schedule defaults', () => {
|
||||
beforeEach(() => {
|
||||
@@ -28,3 +28,12 @@ describe('muteRules schedule defaults', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeMuteUpdatesDraft', () => {
|
||||
it('includes dev_build_update_available alongside the existing update categories', () => {
|
||||
const draft = nodeMuteUpdatesDraft(1, 'edge');
|
||||
expect(draft.categories).toEqual([
|
||||
'image_update_available', 'node_update_available', 'dev_build_update_available', 'update_started',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRule
|
||||
return {
|
||||
name: `Mute ${nodeName} update notifications`,
|
||||
node_id: nodeId,
|
||||
categories: ['image_update_available', 'node_update_available', 'update_started'],
|
||||
categories: ['image_update_available', 'node_update_available', 'dev_build_update_available', 'update_started'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,5 +19,6 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
||||
health_gate_failed: 'Health gate failed',
|
||||
rollback_generation_released: 'Rollback protection released',
|
||||
node_update_available: 'Node update',
|
||||
dev_build_update_available: 'Dev build update',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user