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:
Anso
2026-08-30 19:54:19 +00:00
committed by GitHub
parent c6d9fb98e5
commit 275c654407
36 changed files with 1803 additions and 93 deletions
+1
View File
@@ -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 &amp; restart
</>
) : isDevUpdate ? (
<>
<FlaskConical className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update &amp; restart
</>
) : (
<>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
+27 -1
View File
@@ -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 {
+136 -45
View File
@@ -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);
+10 -1
View File
@@ -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',
]);
});
});
+1 -1
View File
@@ -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',
};