From 275c654407d44d6af0dbf4b7a2c8deed3f24695a Mon Sep 17 00:00:00 2001
From: Anso
Date: Sun, 30 Aug 2026 19:54:19 +0000
Subject: [PATCH] feat(fleet): detect and update from a new sencho-dev:dev
build (#1871)
* feat(fleet): add self dev-build detection primitives
Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns
the probe's primary digest alongside the match/update/error verdict) with
compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the
verdict and the digest no longer has to probe the same mutable tag twice.
Add detectSelfDevBuildUpdate, which compares the running container's own
image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the
new detailed comparison, laying the groundwork for surfacing dev-build
updates in Fleet.
* feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates
Add two pure predicate functions to helpers/selfUpdateCompose.ts for
identifying Sencho dev repository references and floating tag variants:
- isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev
repository, including digest-pinned and dev- tag variants
- isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag
on the Sencho dev repository (not digest-pinned, not immutable dev-)
Both functions reuse existing parsing patterns (normalizeImageRepository for repository
extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency.
Add comprehensive test coverage in self-update-compose.test.ts covering all specified
test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.).
* feat(gitops): wire dev-build detection into MonitorService
Adds a dev_build_update_available notification category and a new
checkSenchoDevBuild() cycle in MonitorService that detects when the
running container has fallen behind the rolling
ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using
detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability
state is written unconditionally so the Fleet update affordance never
depends on notification delivery succeeding, while a separate dedup
key prevents re-notifying for a digest already announced. Also guards
checkSenchoVersion() so a dev-repo pin no longer produces a false
positive stable-release update notification.
* feat(fleet): surface dev-image status and build availability
Fleet's GET /update-status now reports isDevImage (any reference to the
sencho-dev repository, including digest pins) and devBuildUpdateAvailable
(the exact floating :dev tag with a newer build observed, read from the
system-state key MonitorService already maintains). A dev-pinned local
node forces updateAvailable to false and clears any stale stable-release
skip, since that skip was computed before image-pin classification and
would otherwise leak a bogus "Skipped" state onto a dev row.
Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so
both call sites share one string instead of duplicating it.
* fix(fleet): omit targetVersion for a dev-image update trigger
updateRequestInit() always forwarded latestVersion (the latest stable
release) as targetVersion whenever it was valid semver, even for a
dev-pinned node. The backend already ignores targetVersion safely for a
floating pin, so this never caused an actual repin, but it produced a
misleading "Update to X.Y.Z" button label and confirm-dialog copy for an
update that installs the dev image, not that stable release.
* feat(fleet): add integration-image badge and dev build update button
NodeCard now shows a persistent "Integration image" badge whenever a
node's compose image is any sencho-dev reference, independent of update
availability, visible to every role. When a newer dev build is available,
a solid brand-colored "Update dev build" button appears alongside it,
admin-only, reusing the existing update trigger and requireAdmin route.
Styled distinctly from the neutral stable "Update to X.Y.Z" button so an
operator always knows which channel they're acting on.
* feat(fleet): add dev-image copy to the local update confirm dialog
LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct
LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference
will be pulled without rewriting the compose image, and that integration
images are unsigned and carry no release attestations. Without this, a
dev-pinned node's update confirmation fell through to the generic "Pulls
Sencho the latest release" copy. FleetView.tsx threads isDevImage from
the node's update status through to the dialog, same source as its other
pin fields.
* feat(fleet): separate dev and stable availability in the Node Updates sheet
The sheet counted stable and dev availability together via the same
updateAvailable field, so a dev-pinned node with a build available fell
into neither the summary counts nor any row action, and would have
misleadingly rendered as "Up to date" once devBuildUpdateAvailable
existed. stableAvailable and devAvailable are now tracked separately: the
changelog dot lights only from stableAvailable (a dev build has no
release changelog), the summary and meta text report the combined total,
a dev row shows "Integration build" instead of a stable version in the
Latest column, and the existing Update button/badge now also fires for
devBuildUpdateAvailable. Update all and Skip stay stable-only, since both
already gate on fields a dev row never satisfies.
* feat(fleet): bring dev-build detection and update to Mobile Fleet
Mobile Fleet previously had no update capability at all: it only polled
/fleet/overview and never called useFleetUpdateStatus, so it could not
show the stable update flow either. It now fetches update status
alongside the overview poll, shows the same "integration" marker as
desktop on any dev-pinned node's card (visible to every role), and gives
admins a dev-build update action.
The action renders as a sibling of the card's own button rather than
nested inside it, since the card is itself a 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.
---
backend/src/__tests__/monitor-service.test.ts | 245 ++++++++++++++++++
.../src/__tests__/node-update-skips.test.ts | 37 +++
backend/src/__tests__/registry-api.test.ts | 63 +++++
.../__tests__/self-dev-build-detect.test.ts | 148 +++++++++++
.../src/__tests__/self-update-compose.test.ts | 104 ++++++++
.../__tests__/self-update-no-repin.test.ts | 160 ++++++++++++
.../self-update-pinned-routes.test.ts | 66 +++++
backend/src/helpers/selfUpdateCompose.ts | 35 +++
backend/src/routes/fleet.ts | 32 ++-
backend/src/services/MonitorService.ts | 123 +++++++++
backend/src/services/NotificationService.ts | 3 +-
backend/src/services/registry-api.ts | 63 +++--
backend/src/services/selfDevBuildDetect.ts | 91 +++++++
docs/features/alerts-notifications.mdx | 1 +
docs/features/fleet-view.mdx | 10 +-
docs/features/remote-updates.mdx | 4 +-
docs/operations/upgrade.mdx | 4 +
docs/operations/verifying-images.mdx | 2 +
frontend/src/components/FleetView.tsx | 1 +
.../FleetView/LocalUpdateConfirmDialog.tsx | 23 +-
.../src/components/FleetView/NodeCard.tsx | 28 +-
.../components/FleetView/NodeUpdatesSheet.tsx | 31 ++-
.../LocalUpdateConfirmDialog.test.tsx | 55 +++-
.../FleetView/__tests__/NodeCard.test.tsx | 73 ++++++
.../__tests__/NodeUpdatesSheet.test.tsx | 70 ++++-
.../__tests__/useFleetUpdateStatus.test.tsx | 50 ++++
.../FleetView/hooks/useFleetUpdateStatus.ts | 12 +-
frontend/src/components/FleetView/types.ts | 8 +
frontend/src/components/NotificationPanel.tsx | 3 +-
frontend/src/components/dashboard/types.ts | 1 +
.../src/components/mobile/MobileFleet.tsx | 181 +++++++++----
.../mobile/__tests__/MobileFleet.test.tsx | 148 +++++++++++
.../__tests__/notificationVisibility.test.ts | 7 +
frontend/src/lib/muteRules.test.ts | 11 +-
frontend/src/lib/muteRules.ts | 2 +-
frontend/src/lib/notificationCategories.ts | 1 +
36 files changed, 1803 insertions(+), 93 deletions(-)
create mode 100644 backend/src/__tests__/self-dev-build-detect.test.ts
create mode 100644 backend/src/__tests__/self-update-no-repin.test.ts
create mode 100644 backend/src/services/selfDevBuildDetect.ts
create mode 100644 frontend/src/components/mobile/__tests__/MobileFleet.test.tsx
diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts
index 39003a15..a35e387b 100644
--- a/backend/src/__tests__/monitor-service.test.ts
+++ b/backend/src/__tests__/monitor-service.test.ts
@@ -22,6 +22,10 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockGetLatestVersion,
mockGetLatestVersionInfo,
mockGetSenchoVersion,
+ mockGetPinInfo,
+ mockGetIdentity,
+ mockGetAuthForRegistry,
+ mockDetectSelfDevBuildUpdate,
} = vi.hoisted(() => ({
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockGetNodes: vi.fn().mockReturnValue([]),
@@ -57,6 +61,16 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockGetLatestVersion: vi.fn().mockResolvedValue(null),
mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null),
mockGetSenchoVersion: vi.fn().mockReturnValue(null),
+ // Default: no known compose pin, so checkSenchoVersion() falls through
+ // unchanged and checkSenchoDevBuild() is a no-op, matching pre-existing
+ // test expectations.
+ mockGetPinInfo: vi.fn().mockResolvedValue(null),
+ mockGetIdentity: vi.fn().mockReturnValue({
+ containerId: null, containerName: null, composeProjectName: null,
+ imageId: null, networkNames: [], volumeNames: [],
+ }),
+ mockGetAuthForRegistry: vi.fn().mockResolvedValue(null),
+ mockDetectSelfDevBuildUpdate: vi.fn().mockResolvedValue({ kind: 'up_to_date' }),
}));
vi.mock('../services/DatabaseService', () => ({
@@ -125,6 +139,34 @@ vi.mock('../services/NotificationService', () => ({
},
}));
+vi.mock('../services/SelfUpdateService', () => ({
+ default: {
+ getInstance: () => ({
+ getPinInfo: mockGetPinInfo,
+ }),
+ },
+}));
+
+vi.mock('../services/SelfIdentityService', () => ({
+ default: {
+ getInstance: () => ({
+ getIdentity: mockGetIdentity,
+ }),
+ },
+}));
+
+vi.mock('../services/RegistryService', () => ({
+ RegistryService: {
+ getInstance: () => ({
+ getAuthForRegistry: mockGetAuthForRegistry,
+ }),
+ },
+}));
+
+vi.mock('../services/selfDevBuildDetect', () => ({
+ detectSelfDevBuildUpdate: (...args: unknown[]) => mockDetectSelfDevBuildUpdate(...args),
+}));
+
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
@@ -165,6 +207,13 @@ beforeEach(() => {
(MonitorService as any).instance = undefined;
_resetHostAlertSuppressionStateForTests();
mockGetSystemState.mockReturnValue(null);
+ mockGetPinInfo.mockResolvedValue(null);
+ mockGetIdentity.mockReturnValue({
+ containerId: null, containerName: null, composeProjectName: null,
+ imageId: null, networkNames: [], volumeNames: [],
+ });
+ mockGetAuthForRegistry.mockResolvedValue(null);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
});
// si.mem() returns active/available alongside used (used counts reclaimable page
@@ -1378,6 +1427,202 @@ describe('MonitorService - Sencho version check', () => {
});
});
+describe('MonitorService - Sencho dev build check', () => {
+ const DEV_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/compose/docker-compose.yml' };
+ const DEV_SHA_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234', filePath: '/compose/docker-compose.yml' };
+ const DEV_DIGEST_PIN = { pinKind: 'digest' as const, composeImageRef: `ghcr.io/studio-saelix/sencho-dev@sha256:${'a'.repeat(64)}`, filePath: '/compose/docker-compose.yml' };
+ const STABLE_PIN = { pinKind: 'semver' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho:0.97.1', filePath: '/compose/docker-compose.yml' };
+
+ /** In-memory system_state so get/set round-trip within one evaluation. */
+ function wireStatefulSystemState(seed: Record = {}) {
+ const store: Record = { ...seed };
+ mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
+ mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
+ return store;
+ }
+
+ async function runEvaluate(): Promise {
+ await (MonitorService.getInstance() as any).evaluate();
+ }
+
+ /**
+ * checkSenchoDevBuild() is cadence-gated (5 or 30 minutes), so back-to-back
+ * calls in a single test are otherwise short-circuited before the detector
+ * ever runs. Tests that exercise a second check bypass the gate directly;
+ * tests targeting the gate itself assert `lastDevBuildCheckGateMs` instead.
+ */
+ function bypassCadenceGate() {
+ (MonitorService.getInstance() as any).lastDevBuildCheckAt = 0;
+ }
+
+ function devBuildCalls(): unknown[][] {
+ return mockDispatchAlert.mock.calls.filter(
+ (args: unknown[]) => args[1] === 'dev_build_update_available',
+ );
+ }
+
+ beforeEach(() => {
+ mockGetGlobalSettings.mockReturnValue({});
+ mockGetNodes.mockReturnValue([]);
+ mockGetStackAlerts.mockReturnValue([]);
+ mockGetIdentity.mockReturnValue({
+ containerId: null, containerName: null, composeProjectName: null,
+ imageId: 'deadbeefcafe0000', networkNames: [], volumeNames: [],
+ });
+ });
+
+ it('dispatches and persists dedup state on first detected update', async () => {
+ const store = wireStatefulSystemState();
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
+
+ await runEvaluate();
+
+ expect(devBuildCalls()).toHaveLength(1);
+ expect(devBuildCalls()[0][2]).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
+ expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
+ expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
+ });
+
+ it('does not re-notify for the same digest (dedup)', async () => {
+ const store = wireStatefulSystemState();
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
+ await runEvaluate();
+ expect(devBuildCalls()).toHaveLength(1);
+
+ bypassCadenceGate();
+ await runEvaluate();
+
+ expect(devBuildCalls()).toHaveLength(1);
+ expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
+ expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
+ });
+
+ it('notifies again when a later digest appears', async () => {
+ const store = wireStatefulSystemState();
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
+ await runEvaluate();
+
+ bypassCadenceGate();
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d2' });
+ await runEvaluate();
+
+ expect(devBuildCalls()).toHaveLength(2);
+ expect(store.sencho_dev_build_available_digest).toBe('sha256:d2');
+ expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d2');
+ });
+
+ it('clears the availability key when up to date', async () => {
+ const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:old' });
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
+
+ await runEvaluate();
+
+ expect(store.sencho_dev_build_available_digest).toBe('');
+ expect(devBuildCalls()).toHaveLength(0);
+ });
+
+ it('does not touch state when inconclusive, and uses the short cadence gate for the next check', async () => {
+ const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:existing' });
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'inconclusive', reason: 'registry unreachable' });
+
+ await runEvaluate();
+
+ expect(store.sencho_dev_build_available_digest).toBe('sha256:existing');
+ expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
+ expect(devBuildCalls()).toHaveLength(0);
+ expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
+ });
+
+ it('keeps the availability key set but leaves the notified key unchanged when the dispatch is not persisted, and retries next check', async () => {
+ const store = wireStatefulSystemState();
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
+ mockDispatchAlert.mockResolvedValueOnce({ persisted: false });
+
+ await runEvaluate();
+
+ expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
+ expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
+ expect(devBuildCalls()).toHaveLength(1);
+ expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
+
+ bypassCadenceGate();
+ mockDispatchAlert.mockResolvedValueOnce({ persisted: true });
+ await runEvaluate();
+
+ expect(devBuildCalls()).toHaveLength(2);
+ expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
+ });
+
+ it('makes no registry call for an immutable dev- tag', async () => {
+ mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
+
+ await runEvaluate();
+
+ expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
+ expect(mockSetSystemState).not.toHaveBeenCalled();
+ expect(devBuildCalls()).toHaveLength(0);
+ });
+
+ it('makes no registry call for a digest-pinned dev-repo ref', async () => {
+ mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
+
+ await runEvaluate();
+
+ expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
+ expect(mockSetSystemState).not.toHaveBeenCalled();
+ expect(devBuildCalls()).toHaveLength(0);
+ });
+
+ it('is a no-op for a stable-pinned node, and leaves the existing stable version-update behavior unchanged', async () => {
+ mockGetPinInfo.mockResolvedValue(STABLE_PIN);
+ mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
+ mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
+
+ await runEvaluate();
+
+ expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
+ expect(devBuildCalls()).toHaveLength(0);
+ expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
+ });
+
+ it('suppresses the stable version-update notification for a dev-repo :dev pin even when a stable update is available', async () => {
+ mockGetPinInfo.mockResolvedValue(DEV_PIN);
+ mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
+ mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
+ mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
+
+ await runEvaluate();
+
+ expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
+ });
+
+ it('suppresses the stable version-update notification for an immutable dev- pin', async () => {
+ mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
+ mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
+ mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
+
+ await runEvaluate();
+
+ expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
+ });
+
+ it('suppresses the stable version-update notification for a digest-pinned dev-repo ref', async () => {
+ mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
+ mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
+ mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
+
+ await runEvaluate();
+
+ expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
+ });
+});
+
// ── Per-container parallel fan-out ────────────────────────────────────
describe('MonitorService - parallel container processing', () => {
diff --git a/backend/src/__tests__/node-update-skips.test.ts b/backend/src/__tests__/node-update-skips.test.ts
index 2f75926e..3e72fe57 100644
--- a/backend/src/__tests__/node-update-skips.test.ts
+++ b/backend/src/__tests__/node-update-skips.test.ts
@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
+import { MonitorService } from '../services/MonitorService';
let tmpDir: string;
let app: import('express').Express;
@@ -13,6 +14,16 @@ let adminAuth: string;
let viewerAuth: string;
let localNodeId: number;
let db: import('../services/DatabaseService').DatabaseService;
+let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
+
+function mockCompareTargetFetch() {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
+ new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+}
function signToken(username: string, role: string) {
return jwt.sign(
@@ -31,6 +42,7 @@ beforeAll(async () => {
localNodeId = db.getNodes().find(n => n.type === 'local')!.id;
const index = await import('../index');
app = index.app;
+ SelfUpdateService = (await import('../services/SelfUpdateService')).default;
});
afterAll(() => {
@@ -204,3 +216,28 @@ describe('node_update_skips table lifecycle', () => {
expect(() => db.deleteNodeUpdateSkip(99999)).not.toThrow();
});
});
+
+describe('stale stable skip does not leak onto a dev-pinned node', () => {
+ it('clears skipActive/skippedVersion in the response once the node is dev-pinned', async () => {
+ mockCompareTargetFetch();
+ // A skip stored while the node still tracked the stable release train.
+ db.setNodeUpdateSkip(localNodeId, '0.99.0', TEST_USERNAME);
+ vi.spyOn(SelfUpdateService.getInstance(), 'getPinInfo').mockResolvedValue({
+ pinKind: 'floating',
+ composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
+ filePath: '/opt/sencho/docker-compose.yml',
+ });
+ db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
+
+ const res = await request(app)
+ .get('/api/fleet/update-status')
+ .set('Authorization', adminAuth);
+
+ expect(res.status).toBe(200);
+ const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
+ expect(local.skipActive).toBe(false);
+ expect(local.skippedVersion).toBeNull();
+ expect(local.isDevImage).toBe(true);
+ expect(local.devBuildUpdateAvailable).toBe(true);
+ });
+});
diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts
index 4e93cab1..ca469f8e 100644
--- a/backend/src/__tests__/registry-api.test.ts
+++ b/backend/src/__tests__/registry-api.test.ts
@@ -64,6 +64,7 @@ import {
selectLocalRepoDigest,
selectLocalRepoDigests,
compareLocalToRemoteTag,
+ compareLocalToRemoteTagDetailed,
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
MANIFEST_INDEX_DESCRIPTOR_CAP,
MANIFEST_INDEX_MAX_DEPTH,
@@ -1528,4 +1529,66 @@ describe('compareLocalToRemoteTag', () => {
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
+
+ // ─── compareLocalToRemoteTagDetailed ───────────────────────
+ //
+ // Same comparison engine as compareLocalToRemoteTag, sharing the fixtures
+ // and route helpers above, but asserting the extra primaryDigest field and
+ // that compareLocalToRemoteTag itself no longer leaks it.
+
+ describe('compareLocalToRemoteTagDetailed', () => {
+ /** HEAD the tag for INDEX_DIGEST, then serve its index body on the pinned GET. */
+ const routeExpandableIndex = routePrimaryDigest(INDEX_DIGEST, { [INDEX_DIGEST]: STANDARD_INDEX_BODY });
+ /** HEAD the tag for INDEX_DIGEST; every digest-pinned GET fails. */
+ const routeHeadOnly = routePrimaryDigest(INDEX_DIGEST, {});
+
+ it('performs exactly one manifest probe and returns the primary digest alongside kind: match', async () => {
+ route = routeHeadOnly;
+ const result = await compareLocalToRemoteTagDetailed([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
+ expect(result).toEqual({ kind: 'match', primaryDigest: INDEX_DIGEST });
+ expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
+ });
+
+ it('returns the primary digest alongside kind: update when every local candidate is stale', async () => {
+ route = routeExpandableIndex;
+ const result = await compareLocalToRemoteTagDetailed([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
+ expect(result).toEqual({ kind: 'update', primaryDigest: INDEX_DIGEST });
+ });
+
+ it('returns the primary digest alongside kind: error from a classification failure', async () => {
+ // Digest-pinned GET fails: classification cannot complete.
+ route = routeHeadOnly;
+ const result = await compareLocalToRemoteTagDetailed([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
+ expect(result.kind).toBe('error');
+ expect(result.primaryDigest).toBe(INDEX_DIGEST);
+ expect(result.reason).toBeTruthy();
+ });
+
+ it('omits the primary digest on an early error before any probe (malformed local digest)', async () => {
+ const result = await compareLocalToRemoteTagDetailed(['not-a-digest'], REGISTRY, REPO, TAG, AMD64);
+ expect(result).toEqual({ kind: 'error', reason: expect.any(String) });
+ expect(result.primaryDigest).toBeUndefined();
+ });
+
+ it('compareLocalToRemoteTag wraps the detailed result and drops primaryDigest on match', async () => {
+ route = routeHeadOnly;
+ const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
+ expect(Object.keys(result)).toEqual(['kind']);
+ expect(result).toEqual({ kind: 'match' });
+ });
+
+ it('compareLocalToRemoteTag wraps the detailed result and drops primaryDigest on update', async () => {
+ route = routeExpandableIndex;
+ const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
+ expect(Object.keys(result)).toEqual(['kind']);
+ expect(result).toEqual({ kind: 'update' });
+ });
+
+ it('compareLocalToRemoteTag wraps the detailed result and keeps only kind + reason on error', async () => {
+ route = (url) => tokenOk(url) ?? { statusCode: 500, headers: {} };
+ const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
+ expect(result.kind).toBe('error');
+ expect(Object.keys(result).sort()).toEqual(['kind', 'reason']);
+ });
+ });
});
diff --git a/backend/src/__tests__/self-dev-build-detect.test.ts b/backend/src/__tests__/self-dev-build-detect.test.ts
new file mode 100644
index 00000000..45b774b5
--- /dev/null
+++ b/backend/src/__tests__/self-dev-build-detect.test.ts
@@ -0,0 +1,148 @@
+/**
+ * Unit tests for detectSelfDevBuildUpdate: the self-image build detector that
+ * compares the running Sencho container's image against the rolling
+ * ghcr.io/studio-saelix/sencho-dev:dev tag. Drives the function entirely
+ * through the deps injection point (inspectImage, compareDetailed) so no
+ * real Docker socket or registry call is involved.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import {
+ detectSelfDevBuildUpdate,
+ type DetectSelfDevBuildUpdateDeps,
+ type SelfDevBuildDetectInput,
+} from '../services/selfDevBuildDetect';
+import type { compareLocalToRemoteTagDetailed } from '../services/registry-api';
+
+const REGISTRY = 'ghcr.io';
+const REPO = 'studio-saelix/sencho-dev';
+const TAG = 'dev';
+
+const LOCAL_DIGEST = `sha256:${'b'.repeat(64)}`;
+const NEW_DIGEST = `sha256:${'c'.repeat(64)}`;
+const LOCAL_REPO_DIGEST = `${REGISTRY}/${REPO}@${LOCAL_DIGEST}`;
+
+const INPUT: SelfDevBuildDetectInput = {
+ runningImageId: 'a'.repeat(64),
+ registry: REGISTRY,
+ repo: REPO,
+ tag: TAG,
+ credentials: null,
+};
+
+function inspectReturning(repoDigests: string[]) {
+ return vi.fn().mockResolvedValue({ RepoDigests: repoDigests, Os: 'linux', Architecture: 'amd64' });
+}
+
+function compareReturning(
+ result: Awaited>,
+): typeof compareLocalToRemoteTagDetailed {
+ return vi.fn().mockResolvedValue(result);
+}
+
+describe('detectSelfDevBuildUpdate', () => {
+ it('returns up_to_date on a parent-index digest match (local digest equals a remote index primary digest)', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
+ compareDetailed: compareReturning({ kind: 'match', primaryDigest: LOCAL_DIGEST }),
+ };
+ expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'up_to_date' });
+ });
+
+ it('returns up_to_date on platform-child membership (local RepoDigest is the platform-specific child, not the parent index digest)', async () => {
+ // The compareDetailed injection point owns the index-expansion logic; this
+ // test only needs to prove the detector trusts that verdict rather than
+ // doing its own raw digest equality check, which would wrongly report an
+ // update here since LOCAL_DIGEST != the (hypothetical) parent index digest.
+ const compareDetailed = compareReturning({ kind: 'match', primaryDigest: `sha256:${'d'.repeat(64)}` });
+ const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: inspectReturning([LOCAL_REPO_DIGEST]), compareDetailed };
+
+ expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'up_to_date' });
+ expect(compareDetailed).toHaveBeenCalledWith(
+ [LOCAL_DIGEST],
+ REGISTRY,
+ REPO,
+ TAG,
+ { os: 'linux', architecture: 'amd64' },
+ null,
+ );
+ });
+
+ it('returns update with the new digest when the remote has a genuinely newer build', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
+ compareDetailed: compareReturning({ kind: 'update', primaryDigest: NEW_DIGEST }),
+ };
+ expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'update', digest: NEW_DIGEST });
+ });
+
+ it('returns inconclusive when RepoDigests is empty', async () => {
+ const compareDetailed = vi.fn();
+ const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: inspectReturning([]), compareDetailed };
+
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result.kind).toBe('inconclusive');
+ expect(compareDetailed).not.toHaveBeenCalled();
+ });
+
+ it('returns inconclusive when RepoDigests is present but none match the target registry/repo', async () => {
+ const compareDetailed = vi.fn();
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([`docker.io/library/other@sha256:${'e'.repeat(64)}`]),
+ compareDetailed,
+ };
+
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result.kind).toBe('inconclusive');
+ expect(compareDetailed).not.toHaveBeenCalled();
+ });
+
+ it('returns inconclusive, not a throw, when inspectImage throws', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: vi.fn().mockRejectedValue(new Error('no such image')),
+ compareDetailed: vi.fn(),
+ };
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('no such image') });
+ });
+
+ it('returns inconclusive with the carried-through reason when compareDetailed returns kind: error', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
+ compareDetailed: compareReturning({ kind: 'error', reason: 'Registry unreachable for ghcr.io/studio-saelix/sencho-dev:dev' }),
+ };
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result).toEqual({ kind: 'inconclusive', reason: 'Registry unreachable for ghcr.io/studio-saelix/sencho-dev:dev' });
+ });
+
+ it('returns inconclusive (not a fabricated update) when compareDetailed returns kind: update with no primaryDigest', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
+ compareDetailed: compareReturning({ kind: 'update' }),
+ };
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('no digest') });
+ });
+
+ it('returns inconclusive, not a throw, when compareDetailed rejects', async () => {
+ const deps: DetectSelfDevBuildUpdateDeps = {
+ inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
+ compareDetailed: vi.fn().mockRejectedValue(new Error('registry socket reset')),
+ };
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('registry socket reset') });
+ });
+
+ it('returns inconclusive, not a throw, when inspectImage resolves with a non-array RepoDigests', async () => {
+ // A misbehaving injected inspectImage (or a future Docker SDK shape change)
+ // can violate its declared return type at runtime; the cast below
+ // constructs exactly that violation on purpose to prove the detector
+ // guards against it rather than trusting the type signature blindly.
+ const malformedInspect = vi.fn().mockResolvedValue({ RepoDigests: undefined, Os: 'linux', Architecture: 'amd64' }) as unknown as NonNullable;
+ const compareDetailed = vi.fn();
+ const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: malformedInspect, compareDetailed };
+
+ const result = await detectSelfDevBuildUpdate(INPUT, deps);
+ expect(result.kind).toBe('inconclusive');
+ expect(compareDetailed).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/src/__tests__/self-update-compose.test.ts b/backend/src/__tests__/self-update-compose.test.ts
index 2f9409e9..9b32033c 100644
--- a/backend/src/__tests__/self-update-compose.test.ts
+++ b/backend/src/__tests__/self-update-compose.test.ts
@@ -18,6 +18,8 @@ import {
isValidImageRef,
resolveServiceImageFromContents,
patchComposeServiceImage,
+ isSenchoDevRepository,
+ isSenchoDevFloatingTag,
} from '../helpers/selfUpdateCompose';
import {
buildComposeReadArgs,
@@ -322,3 +324,105 @@ describe('buildComposeConfigValidateArgs', () => {
expect(cmd).toContain(shQuote('/opt/sencho/override.yml'));
});
});
+
+describe('isSenchoDevRepository', () => {
+ it('returns true for the floating dev tag', () => {
+ expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
+ });
+
+ it('returns true for an immutable dev- tag', () => {
+ expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d')).toBe(true);
+ });
+
+ it('returns true for a digest-pinned reference', () => {
+ expect(
+ isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
+ ).toBe(true);
+ });
+
+ it('returns true for a reference with both tag and digest', () => {
+ expect(
+ isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
+ ).toBe(true);
+ });
+
+ it('returns false for the stable sencho repository', () => {
+ expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho:1.2.3')).toBe(false);
+ });
+
+ it('returns false for the hardened repository', () => {
+ expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe(false);
+ });
+
+ it('returns false for the Docker Hub stable repository', () => {
+ expect(isSenchoDevRepository('saelix/sencho:latest')).toBe(false);
+ });
+
+ it('returns false for an unrelated image', () => {
+ expect(isSenchoDevRepository('docker.io/library/nginx:latest')).toBe(false);
+ });
+
+ it('returns false for an interpolated variable', () => {
+ expect(isSenchoDevRepository('${SENCHO_IMAGE}')).toBe(false);
+ });
+
+ it('returns false for a registry-with-port reference to an unrelated repository', () => {
+ expect(isSenchoDevRepository('localhost:5000/something:dev')).toBe(false);
+ });
+
+ it('returns false for empty or malformed references', () => {
+ expect(isSenchoDevRepository('')).toBe(false);
+ expect(isSenchoDevRepository(' ')).toBe(false);
+ });
+});
+
+describe('isSenchoDevFloatingTag', () => {
+ it('returns true only for the floating dev tag', () => {
+ expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
+ });
+
+ it('returns false for an immutable dev- tag (not the floating dev tag)', () => {
+ expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d')).toBe(false);
+ });
+
+ it('returns false for a digest-pinned reference (disqualifies floating)', () => {
+ expect(
+ isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
+ ).toBe(false);
+ });
+
+ it('returns false for a reference with both tag and digest (still digest-pinned)', () => {
+ expect(
+ isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
+ ).toBe(false);
+ });
+
+ it('returns false for the stable sencho repository', () => {
+ expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho:1.2.3')).toBe(false);
+ });
+
+ it('returns false for the hardened repository', () => {
+ expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe(false);
+ });
+
+ it('returns false for the Docker Hub stable repository', () => {
+ expect(isSenchoDevFloatingTag('saelix/sencho:latest')).toBe(false);
+ });
+
+ it('returns false for an unrelated image', () => {
+ expect(isSenchoDevFloatingTag('docker.io/library/nginx:latest')).toBe(false);
+ });
+
+ it('returns false for an interpolated variable', () => {
+ expect(isSenchoDevFloatingTag('${SENCHO_IMAGE}')).toBe(false);
+ });
+
+ it('returns false for a registry-with-port reference to an unrelated repository', () => {
+ expect(isSenchoDevFloatingTag('localhost:5000/something:dev')).toBe(false);
+ });
+
+ it('returns false for empty or malformed references', () => {
+ expect(isSenchoDevFloatingTag('')).toBe(false);
+ expect(isSenchoDevFloatingTag(' ')).toBe(false);
+ });
+});
diff --git a/backend/src/__tests__/self-update-no-repin.test.ts b/backend/src/__tests__/self-update-no-repin.test.ts
new file mode 100644
index 00000000..b564adba
--- /dev/null
+++ b/backend/src/__tests__/self-update-no-repin.test.ts
@@ -0,0 +1,160 @@
+/**
+ * Regression coverage for the no-repin invariant on a floating-tag self-update.
+ *
+ * The Fleet dev-build update reaches SelfUpdateService WITH a targetVersion
+ * even though the frontend omits one: the route substitutes the stable compare
+ * target (resolveUpdateTarget in routes/fleet.ts) and forwards it through
+ * ImageOperationService. So the guard that actually protects a :dev install is
+ * not the absence of a target, it is the `pinKind === 'semver'` test inside the
+ * repin branch. The worst-case failure is silently rewriting the compose file
+ * from :dev to a stable tag, which would move the install off the dev channel.
+ *
+ * These exercise the real SelfUpdateService decision rather than a mock
+ * standing in for it, and pair the floating cases with a semver case so the
+ * negative assertions cannot pass vacuously.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { mockExecFile, mockExecFileAsync, mockWriteFileSync } = vi.hoisted(() => ({
+ mockExecFile: vi.fn(),
+ mockExecFileAsync: vi.fn(),
+ mockWriteFileSync: vi.fn(),
+}));
+
+vi.mock('child_process', () => ({
+ exec: vi.fn(),
+ execFile: mockExecFile,
+}));
+vi.mock('util', () => ({
+ promisify: () => mockExecFileAsync,
+}));
+vi.mock('../services/DatabaseService', () => ({
+ DatabaseService: { getInstance: () => ({ getGlobalSettings: () => ({}) }) },
+}));
+// SelfUpdateService imports `fs` as a namespace; spying on the real ESM
+// namespace object throws ("Module namespace is not configurable"), so the
+// write path is swapped for a mock while every other fs function stays real.
+vi.mock('fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, writeFileSync: mockWriteFileSync };
+});
+
+const DEV_IMAGE_REF = 'ghcr.io/studio-saelix/sencho-dev:dev';
+const SEMVER_IMAGE_REF = 'saelix/sencho:0.93.3';
+const WORKING_DIR = '/opt/sencho';
+const COMPOSE_FILE = '/opt/sencho/docker-compose.yml';
+// The stable release the Fleet route resolves and forwards when the caller
+// (a dev-image update) supplies no target of its own.
+const RESOLVED_TARGET = '0.99.0';
+
+// Mirrors SelfUpdateService's private ComposeContext shape (not exported), so
+// the test-only field poke below stays structurally checked against drift.
+type TestComposeContext = {
+ workingDir: string;
+ configFiles: string;
+ serviceName: string;
+ imageName: string;
+ dataDirHost: string | null;
+ hostBindMounts: Array<{ source: string; destination: string }>;
+};
+
+/** The argv SelfUpdateService hands the helper container, or null if unspawned. */
+function helperArgs(): string[] | null {
+ const call = mockExecFile.mock.calls[0] as [string, string[]] | undefined;
+ return call ? call[1] : null;
+}
+
+describe('SelfUpdateService.triggerUpdate (no-repin invariant)', () => {
+ let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
+
+ /** Point the service at a compose project declaring `composeImageRef`. */
+ async function setupWithComposeImage(composeImageRef: string): Promise {
+ vi.clearAllMocks();
+ // `docker pull` yields nothing; the throwaway `cat` container returns the
+ // compose file, which is what the fresh pin resolution actually parses.
+ mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) =>
+ args[0] === 'pull'
+ ? { stdout: '', stderr: '' }
+ : { stdout: `services:\n sencho:\n image: ${composeImageRef}\n`, stderr: '' },
+ );
+ ({ default: SelfUpdateService } = await import('../services/SelfUpdateService'));
+ (SelfUpdateService.getInstance() as unknown as { composeContext: TestComposeContext }).composeContext = {
+ workingDir: WORKING_DIR,
+ configFiles: COMPOSE_FILE,
+ serviceName: 'sencho',
+ imageName: composeImageRef,
+ dataDirHost: '/opt/sencho/data',
+ hostBindMounts: [],
+ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('keeps a :dev pin on its own tag when the route forwards a stable target', async () => {
+ await setupWithComposeImage(DEV_IMAGE_REF);
+
+ // The production call shape: Fleet resolved a stable compare target and
+ // forwarded it, so the repin branch runs and must decline on a floating pin.
+ await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
+
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
+ 'docker',
+ ['pull', DEV_IMAGE_REF],
+ expect.objectContaining({ timeout: 300_000 }),
+ );
+ // The forwarded stable version must never become the pulled reference.
+ expect(mockExecFileAsync).not.toHaveBeenCalledWith(
+ 'docker',
+ ['pull', expect.stringContaining(RESOLVED_TARGET)],
+ expect.anything(),
+ );
+ // A staged compose patch is written only when a repin is committed.
+ expect(mockWriteFileSync).not.toHaveBeenCalled();
+
+ const args = helperArgs();
+ expect(args).not.toBeNull();
+ expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
+ expect(args).not.toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
+ expect(args![args!.length - 1]).not.toContain('cp ');
+ });
+
+ it('pulls the current dev image unchanged when no target is supplied at all', async () => {
+ await setupWithComposeImage(DEV_IMAGE_REF);
+
+ // The legacy pull-current path (no target anywhere) skips the repin branch
+ // outright rather than declining inside it.
+ await SelfUpdateService.getInstance().triggerUpdate();
+
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
+ 'docker',
+ ['pull', DEV_IMAGE_REF],
+ expect.objectContaining({ timeout: 300_000 }),
+ );
+ expect(mockWriteFileSync).not.toHaveBeenCalled();
+ expect(helperArgs()).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
+ });
+
+ it('still repins a semver pin to the target (the assertions above are not vacuous)', async () => {
+ await setupWithComposeImage(SEMVER_IMAGE_REF);
+
+ await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
+
+ // Contrast case: a semver pin is exactly what the floating pin must not do.
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
+ 'docker',
+ ['pull', `saelix/sencho:${RESOLVED_TARGET}`],
+ expect.objectContaining({ timeout: 300_000 }),
+ );
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('.sencho-compose-patch'),
+ expect.stringContaining(`saelix/sencho:${RESOLVED_TARGET}`),
+ 'utf8',
+ );
+
+ const args = helperArgs();
+ expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
+ expect(args![args!.length - 1]).toContain('cp ');
+ });
+});
diff --git a/backend/src/__tests__/self-update-pinned-routes.test.ts b/backend/src/__tests__/self-update-pinned-routes.test.ts
index 051de0bb..1503aac6 100644
--- a/backend/src/__tests__/self-update-pinned-routes.test.ts
+++ b/backend/src/__tests__/self-update-pinned-routes.test.ts
@@ -8,6 +8,7 @@ import request from 'supertest';
import jwt from 'jsonwebtoken';
import { buildTargetImageRef } from '../helpers/selfUpdateCompose';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
+import { MonitorService } from '../services/MonitorService';
let tmpDir: string;
let app: import('express').Express;
@@ -216,4 +217,69 @@ describe('GET /api/fleet/update-status pin projection', () => {
expect(local.updateBlocked).toBe(true);
expect(local.updateBlockedReason).toMatch(/cannot update automatically/i);
});
+
+ it('marks a :dev-pinned local node as a dev image and available when the digest key is set', async () => {
+ mockCompareTargetFetch();
+ mockSelfUpdateAvailable({
+ pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/opt/sencho/docker-compose.yml' },
+ });
+ DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
+
+ const res = await request(app)
+ .get('/api/fleet/update-status')
+ .set('Authorization', adminAuth);
+
+ const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
+ expect(local.isDevImage).toBe(true);
+ expect(local.devBuildUpdateAvailable).toBe(true);
+ expect(local.updateAvailable).toBe(false);
+ });
+
+ it('marks a :dev-pinned local node as a dev image but not available when no digest key is set', async () => {
+ mockCompareTargetFetch();
+ mockSelfUpdateAvailable({
+ pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/opt/sencho/docker-compose.yml' },
+ });
+ DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, '');
+
+ const res = await request(app)
+ .get('/api/fleet/update-status')
+ .set('Authorization', adminAuth);
+
+ const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
+ expect(local.isDevImage).toBe(true);
+ expect(local.devBuildUpdateAvailable).toBe(false);
+ });
+
+ it('marks an immutable dev- pin as a dev image but never eligible for the dev update action', async () => {
+ mockCompareTargetFetch();
+ mockSelfUpdateAvailable({
+ pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d', filePath: '/opt/sencho/docker-compose.yml' },
+ });
+ DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
+
+ const res = await request(app)
+ .get('/api/fleet/update-status')
+ .set('Authorization', adminAuth);
+
+ const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
+ expect(local.isDevImage).toBe(true);
+ expect(local.devBuildUpdateAvailable).toBe(false);
+ });
+
+ it('leaves a stable-pinned local node with both dev fields false', async () => {
+ mockCompareTargetFetch();
+ mockSelfUpdateAvailable({
+ pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.83.0', filePath: '/opt/sencho/docker-compose.yml' },
+ });
+ DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
+
+ const res = await request(app)
+ .get('/api/fleet/update-status')
+ .set('Authorization', adminAuth);
+
+ const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
+ expect(local.isDevImage).toBe(false);
+ expect(local.devBuildUpdateAvailable).toBe(false);
+ });
});
diff --git a/backend/src/helpers/selfUpdateCompose.ts b/backend/src/helpers/selfUpdateCompose.ts
index a34688f6..2c5f9c17 100644
--- a/backend/src/helpers/selfUpdateCompose.ts
+++ b/backend/src/helpers/selfUpdateCompose.ts
@@ -1,5 +1,6 @@
import { parse, parseDocument } from 'yaml';
import semver from 'semver';
+import { normalizeImageRepository } from './imageChannel';
/**
* How the Sencho service's compose `image:` is pinned. Drives the Fleet
@@ -78,6 +79,40 @@ export function isValidImageRef(ref: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$/.test(ref);
}
+/**
+ * True for any reference to the `studio-saelix/sencho-dev` repository on
+ * `ghcr.io`, including digest-pinned references and the immutable `dev-`
+ * tag. Normalizes the image reference to the bare repository string and
+ * compares against the canonical Sencho dev repository identifier.
+ */
+export function isSenchoDevRepository(imageRef: string): boolean {
+ const normalized = normalizeImageRepository(imageRef);
+ return normalized === 'ghcr.io/studio-saelix/sencho-dev';
+}
+
+/**
+ * True only when the image reference points to the Sencho dev repository with
+ * the floating `dev` tag, not digest-pinned. This predicate identifies the
+ * canonical floating tag that users configure for auto-update detection.
+ */
+export function isSenchoDevFloatingTag(imageRef: string): boolean {
+ if (!isSenchoDevRepository(imageRef)) return false;
+
+ const ref = imageRef?.trim();
+ if (!ref) return false;
+
+ // A digest pin disqualifies the reference (e.g., `@sha256:...`)
+ if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false;
+
+ // Extract the tag using the same logic as classifyImagePin.
+ const lastSlash = ref.lastIndexOf('/');
+ const lastColon = ref.lastIndexOf(':');
+ // A colon after the last slash is a tag separator; before it is a registry port.
+ const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
+
+ return tag === 'dev';
+}
+
/**
* Resolve the Sencho service's image from already-read compose file contents.
* Files are given in compose `-f` order; they are scanned in REVERSE so the
diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts
index 059e181c..0b7704dd 100644
--- a/backend/src/routes/fleet.ts
+++ b/backend/src/routes/fleet.ts
@@ -15,6 +15,7 @@ import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { StackOpLockService } from '../services/StackOpLockService';
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
+import { MonitorService } from '../services/MonitorService';
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
@@ -35,7 +36,13 @@ import { getErrorMessage } from '../utils/errors';
import { prepareOutboundRegistryDeliveryBody } from '../helpers/registryDeliveryOutbound';
import { parseIntParam } from '../utils/parseIntParam';
import { parseRequestedTargetVersion, pickCompareTarget } from '../utils/targetVersion';
-import { buildTargetImageRef, isRepinBlocked, type ImagePinKind } from '../helpers/selfUpdateCompose';
+import {
+ buildTargetImageRef,
+ isRepinBlocked,
+ isSenchoDevFloatingTag,
+ isSenchoDevRepository,
+ type ImagePinKind,
+} from '../helpers/selfUpdateCompose';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import { foldNodeEstimate, type FleetEstimateTargetResult, type FleetNodeEstimate } from '../helpers/fleetEstimate';
@@ -1277,11 +1284,19 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
let updateBlocked = false;
let updateBlockedReason: string | null = null;
let imageChannel: 'community' | 'hardened' | 'unknown' | null = null;
+ // Dev-channel fields: local only. Remote nodes do not expose a full
+ // composeImageRef (see the comment above), so a remote's dev-image
+ // status cannot be determined here.
+ let isDevImage = false;
+ let devBuildUpdateAvailable = false;
if (node.type === 'local') {
const pin = await SelfUpdateService.getInstance().getPinInfo();
if (pin) {
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason, imageChannel } =
localPinStatusFields(pin, compareVersion, compareValid, REPIN_BLOCKED_REASON));
+ isDevImage = isSenchoDevRepository(pin.composeImageRef);
+ devBuildUpdateAvailable = isSenchoDevFloatingTag(pin.composeImageRef)
+ && Boolean(db.getSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY));
}
} else {
imagePinKind = remoteImagePinKind;
@@ -1289,6 +1304,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
imageChannel = remoteImageChannel;
}
+ // A dev-pinned image tracks build freshness by digest, not the stable
+ // semver compare target, and its packaged version cannot be compared
+ // against a stable release. A dev row also carries no meaningful
+ // stable skip: the stored skip predates the dev pin, or targets a
+ // stable version this node no longer tracks.
+ if (isDevImage) {
+ updateAvailable = false;
+ skipActive = false;
+ skippedVersion = null;
+ }
+
return {
nodeId: node.id,
name: node.name,
@@ -1306,6 +1332,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
updateBlocked,
updateBlockedReason,
imageChannel,
+ isDevImage,
+ devBuildUpdateAvailable,
operationKind: currentTracker?.operationKind ?? null,
canReapplyCompose: node.type === 'local'
? SelfUpdateService.getInstance().isAvailable()
@@ -1329,6 +1357,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
skipActive: false,
skippedVersion: null,
...EMPTY_PIN_STATUS,
+ isDevImage: false,
+ devBuildUpdateAvailable: false,
operationKind: null,
canReapplyCompose: false,
};
diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts
index 7b86be5f..ede30fe8 100644
--- a/backend/src/services/MonitorService.ts
+++ b/backend/src/services/MonitorService.ts
@@ -11,7 +11,14 @@ import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersionInfo } from '../utils/version-check';
import { getHostMemory } from '../helpers/hostMemory';
import { isDebugEnabled } from '../utils/debug';
+import { sanitizeForLog } from '../utils/safeLog';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
+import SelfUpdateService from './SelfUpdateService';
+import SelfIdentityService from './SelfIdentityService';
+import { RegistryService } from './RegistryService';
+import { parseImageRef } from './registry-api';
+import { detectSelfDevBuildUpdate } from './selfDevBuildDetect';
+import { isSenchoDevRepository, isSenchoDevFloatingTag } from '../helpers/selfUpdateCompose';
const getMetricDetails = (metric: string): { name: string, unit: string } => {
switch (metric) {
@@ -204,6 +211,16 @@ export class MonitorService {
// exits; see backend/src/services/DockerEventService.ts.
private static readonly SENCHO_UPDATE_NOTIFIED_KEY = 'last_sencho_update_notified_version';
+ // Public: the Fleet route reads this same key to derive devBuildUpdateAvailable
+ // without a second polling loop.
+ static readonly SENCHO_DEV_BUILD_AVAILABLE_KEY = 'sencho_dev_build_available_digest';
+ private static readonly SENCHO_DEV_BUILD_NOTIFIED_KEY = 'last_sencho_dev_build_notified_digest';
+
+ // Cadence gate for checkSenchoDevBuild(): 30 minutes after a conclusive
+ // check (up_to_date, or update with a notification decision made), 5
+ // minutes after an inconclusive one so a transient failure retries soon.
+ private lastDevBuildCheckAt = 0;
+ private lastDevBuildCheckGateMs = 30 * 60 * 1000;
private constructor() { }
@@ -375,6 +392,10 @@ export class MonitorService {
// 4. Sencho version update check (cache-backed; dedup prevents re-notify)
await this.checkSenchoVersion();
+
+ // 5. Sencho dev-build update check (only applicable when self-pinned to
+ // the floating ghcr.io/studio-saelix/sencho-dev:dev tag)
+ await this.checkSenchoDevBuild();
}
/**
@@ -573,6 +594,14 @@ export class MonitorService {
* next eval can retry.
*/
private async checkSenchoVersion(): Promise {
+ // A dev-repo pin (floating :dev, immutable dev-, or digest) tracks
+ // rolling builds, not stable semver releases, so the stable-release
+ // update check does not apply; checkSenchoDevBuild() covers it instead.
+ const pin = await SelfUpdateService.getInstance().getPinInfo();
+ if (pin && isSenchoDevRepository(pin.composeImageRef)) {
+ return;
+ }
+
// getSenchoVersion() reads the packaged manifest; npm_package_version
// is unset under `node dist/index.js` (Docker).
const currentVersion = getSenchoVersion();
@@ -625,6 +654,100 @@ export class MonitorService {
}
}
+ /**
+ * Notify when the running Sencho container has fallen behind the rolling
+ * `ghcr.io/studio-saelix/sencho-dev:dev` build it is pinned to. Only
+ * applicable when the compose-declared image is that exact floating tag;
+ * a stable pin, an immutable `dev-` tag, or a digest pin returns
+ * immediately. Availability key `sencho_dev_build_available_digest`
+ * always reflects the latest detection outcome; dedup key
+ * `last_sencho_dev_build_notified_digest` prevents re-notifying for a
+ * digest already announced.
+ */
+ private async checkSenchoDevBuild(): Promise {
+ try {
+ const pin = await SelfUpdateService.getInstance().getPinInfo();
+ if (!pin) return;
+ if (!isSenchoDevFloatingTag(pin.composeImageRef)) return;
+
+ if (Date.now() - this.lastDevBuildCheckAt < this.lastDevBuildCheckGateMs) {
+ return;
+ }
+
+ const imageId = SelfIdentityService.getInstance().getIdentity().imageId;
+ if (!imageId) {
+ if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: own image id unknown; will retry sooner');
+ this.lastDevBuildCheckAt = Date.now();
+ this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
+ return;
+ }
+
+ const parsed = parseImageRef(pin.composeImageRef);
+ if (!parsed) {
+ if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: could not parse compose image ref; will retry sooner');
+ this.lastDevBuildCheckAt = Date.now();
+ this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
+ return;
+ }
+
+ const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
+ const result = await detectSelfDevBuildUpdate({
+ runningImageId: imageId,
+ registry: parsed.registry,
+ repo: parsed.repo,
+ tag: parsed.tag,
+ credentials,
+ });
+
+ const db = DatabaseService.getInstance();
+
+ if (result.kind === 'up_to_date') {
+ db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, '');
+ this.lastDevBuildCheckAt = Date.now();
+ this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
+ if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev build is up-to-date');
+ return;
+ }
+
+ if (result.kind === 'inconclusive') {
+ if (isDebugEnabled()) console.debug(`[Monitor:diag] Sencho dev-build check inconclusive: ${sanitizeForLog(result.reason)}`);
+ this.lastDevBuildCheckAt = Date.now();
+ this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
+ return;
+ }
+
+ // result.kind === 'update': availability reflects reality regardless
+ // of whether the notification itself lands.
+ db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, result.digest);
+
+ if (db.getSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY) === result.digest) {
+ if (isDebugEnabled()) console.debug('[Monitor:diag] Already notified for this Sencho dev build digest');
+ this.lastDevBuildCheckAt = Date.now();
+ this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
+ return;
+ }
+
+ const { persisted } = await NotificationService.getInstance().dispatchAlert(
+ 'info',
+ 'dev_build_update_available',
+ 'A new Sencho dev build is available on ghcr.io/studio-saelix/sencho-dev:dev. '
+ + "Use the update action on this node's Fleet card to pull and recreate.",
+ );
+
+ this.lastDevBuildCheckAt = Date.now();
+ if (persisted) {
+ db.setSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY, result.digest);
+ this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
+ } else {
+ // Retry sooner so an unpersisted notification is not silently
+ // deduped; the availability key above already reflects reality.
+ this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
+ }
+ } catch (e) {
+ console.error('[MonitorService] Failed to check Sencho dev build update:', e);
+ }
+ }
+
private async evaluateStackAlerts(db: DatabaseService) {
const alerts = db.getStackAlerts();
const nodes = db.getNodes();
diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts
index 535685b5..76159725 100644
--- a/backend/src/services/NotificationService.ts
+++ b/backend/src/services/NotificationService.ts
@@ -63,6 +63,7 @@ export type NotificationCategory =
| 'git_apply_rolled_back'
| 'git_create'
| 'node_update_available'
+ | 'dev_build_update_available'
| 'system';
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
@@ -71,7 +72,7 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'autoheal_triggered', 'monitor_alert', 'scan_finding',
'blueprint_deployed', 'blueprint_deployment_failed',
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
- 'node_update_available', 'system',
+ 'node_update_available', 'dev_build_update_available', 'system',
];
/** Every category that can appear in notification history / the bell panel. */
diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts
index 13125668..8e973e58 100644
--- a/backend/src/services/registry-api.ts
+++ b/backend/src/services/registry-api.ts
@@ -785,14 +785,17 @@ async function classifyManifest(
}
/**
- * Compare local image digests to the registry's current manifest for a tag.
- * Any candidate that equals the remote primary or is a member of that primary's
- * index counts as current (Docker often lists a stale index digest ahead of the
- * current one). `platform` is the local image's Os/Architecture (from
- * `docker image inspect`), required to safely match against an index's platform
- * descriptors; without it, an index mismatch is an error rather than a
- * speculative match. Never retries against the mutable tag once a primary
- * digest is established: classification always targets that digest.
+ * Compare local image digests to the registry's current manifest for a tag,
+ * returning the probe's primary digest alongside the verdict so a caller that
+ * needs both (e.g. a self-build detector reporting the new digest) does not
+ * have to re-probe the mutable tag. Any candidate that equals the remote
+ * primary or is a member of that primary's index counts as current (Docker
+ * often lists a stale index digest ahead of the current one). `platform` is
+ * the local image's Os/Architecture (from `docker image inspect`), required
+ * to safely match against an index's platform descriptors; without it, an
+ * index mismatch is an error rather than a speculative match. Never retries
+ * against the mutable tag once a primary digest is established:
+ * classification always targets that digest.
*
* `update` is returned only after a successful, complete remote classification
* with no candidate matching the primary, an exact member, or a same-platform
@@ -805,16 +808,20 @@ async function classifyManifest(
* instead. Empty/all-malformed candidates, unknown platform when platform
* matching is required, an index with no runnable content for the local
* platform at all (including an empty or fully-filtered index), and
- * classification failures also return `error`.
+ * classification failures also return `error`. `primaryDigest` is present
+ * once a valid primary digest has been read from the registry, including on
+ * `error` results from a later classification failure; it is absent only
+ * when the local candidate digests are malformed, the probe itself failed,
+ * or the registry's primary digest fails validation.
*/
-export async function compareLocalToRemoteTag(
+export async function compareLocalToRemoteTagDetailed(
localDigests: readonly string[],
registry: string,
repo: string,
tag: string,
platform: { os: string; architecture: string },
credentials?: RegistryCredentials | null,
-): Promise {
+): Promise<{ kind: 'match' | 'update' | 'error'; primaryDigest?: string; reason?: string }> {
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
if (candidates.length === 0) {
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
@@ -829,21 +836,21 @@ export async function compareLocalToRemoteTag(
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
}
- if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
+ if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match', primaryDigest };
let classification: ManifestClassification;
try {
classification = await classifyManifest(registry, repo, primaryDigest, contentType, body, authHeaders, ref);
} catch (e) {
- return { kind: 'error', reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
+ return { kind: 'error', primaryDigest, reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
}
- if (classification.kind === 'single') return { kind: 'update' };
+ if (classification.kind === 'single') return { kind: 'update', primaryDigest };
- if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
+ if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match', primaryDigest };
if (!platform.os || !platform.architecture) {
- return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
+ return { kind: 'error', primaryDigest, reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
}
const platformDescriptors = classification.descriptors.filter(
@@ -860,16 +867,32 @@ export async function compareLocalToRemoteTag(
// content, since nothing else claims a different one; that is the one
// case where reporting `update` instead of failing closed is safe.
if (classification.exactDigests.length === 0) {
- return { kind: 'error', reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
+ return { kind: 'error', primaryDigest, reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
}
if (classification.descriptors.length > 0) {
- return { kind: 'error', reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
+ return { kind: 'error', primaryDigest, reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
}
- return { kind: 'update' };
+ return { kind: 'update', primaryDigest };
}
const isMember = platformDescriptors.some((d) => candidateSet.has(d.digest.toLowerCase()));
- return isMember ? { kind: 'match' } : { kind: 'update' };
+ return isMember ? { kind: 'match', primaryDigest } : { kind: 'update', primaryDigest };
+}
+
+/**
+ * Verdict-only view of {@link compareLocalToRemoteTagDetailed} for callers
+ * that never need the primary digest.
+ */
+export async function compareLocalToRemoteTag(
+ localDigests: readonly string[],
+ registry: string,
+ repo: string,
+ tag: string,
+ platform: { os: string; architecture: string },
+ credentials?: RegistryCredentials | null,
+): Promise {
+ const result = await compareLocalToRemoteTagDetailed(localDigests, registry, repo, tag, platform, credentials);
+ return result.kind === 'error' ? { kind: 'error', reason: result.reason ?? 'Unknown error' } : { kind: result.kind };
}
export type TagListCode =
diff --git a/backend/src/services/selfDevBuildDetect.ts b/backend/src/services/selfDevBuildDetect.ts
new file mode 100644
index 00000000..65cf4cfa
--- /dev/null
+++ b/backend/src/services/selfDevBuildDetect.ts
@@ -0,0 +1,91 @@
+/**
+ * Detects whether the running Sencho container's own image has fallen behind
+ * the rolling `ghcr.io/studio-saelix/sencho-dev:dev` build it tracks. Reuses
+ * {@link compareLocalToRemoteTagDetailed} so the verdict and the new digest
+ * come from a single registry probe.
+ */
+import { getErrorMessage } from '../utils/errors';
+import DockerController from './DockerController';
+import {
+ compareLocalToRemoteTagDetailed,
+ selectLocalRepoDigests,
+ type RegistryCredentials,
+} from './registry-api';
+
+export interface SelfDevBuildDetectInput {
+ /** Container's actual running image ID (no "sha256:" prefix). */
+ runningImageId: string;
+ registry: string;
+ repo: string;
+ tag: string;
+ credentials: RegistryCredentials | null;
+}
+
+export type SelfDevBuildDetectResult =
+ | { kind: 'up_to_date' }
+ | { kind: 'update'; digest: string }
+ | { kind: 'inconclusive'; reason: string };
+
+/** The subset of `docker image inspect` output the detector reads. */
+interface InspectedImage {
+ RepoDigests: string[];
+ Os: string;
+ Architecture: string;
+}
+
+async function defaultInspectImage(imageId: string): Promise {
+ const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect();
+ return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture };
+}
+
+export interface DetectSelfDevBuildUpdateDeps {
+ inspectImage?: typeof defaultInspectImage;
+ compareDetailed?: typeof compareLocalToRemoteTagDetailed;
+}
+
+export async function detectSelfDevBuildUpdate(
+ input: SelfDevBuildDetectInput,
+ deps?: DetectSelfDevBuildUpdateDeps,
+): Promise {
+ const { runningImageId, registry, repo, tag, credentials } = input;
+ const inspectImage = deps?.inspectImage ?? defaultInspectImage;
+ const compareDetailed = deps?.compareDetailed ?? compareLocalToRemoteTagDetailed;
+
+ let inspected: InspectedImage;
+ try {
+ inspected = await inspectImage(runningImageId);
+ } catch (e) {
+ return { kind: 'inconclusive', reason: getErrorMessage(e, 'Failed to inspect the running image') };
+ }
+
+ if (!Array.isArray(inspected.RepoDigests) || inspected.RepoDigests.length === 0) {
+ return { kind: 'inconclusive', reason: 'Running image has no registry digests (not registry-backed or locally built)' };
+ }
+
+ const localDigests = selectLocalRepoDigests(inspected.RepoDigests, { registry, repo, tag });
+ if (localDigests.length === 0) {
+ return { kind: 'inconclusive', reason: `Running image has no digest matching ${registry}/${repo}:${tag}` };
+ }
+
+ let result: Awaited>;
+ try {
+ result = await compareDetailed(
+ localDigests,
+ registry,
+ repo,
+ tag,
+ { os: inspected.Os, architecture: inspected.Architecture },
+ credentials,
+ );
+ } catch (e) {
+ return { kind: 'inconclusive', reason: getErrorMessage(e, 'Registry comparison failed') };
+ }
+
+ if (result.kind === 'match') return { kind: 'up_to_date' };
+ if (result.kind === 'error') return { kind: 'inconclusive', reason: result.reason ?? 'Registry probe failed' };
+
+ if (!result.primaryDigest) {
+ return { kind: 'inconclusive', reason: 'Registry reported an update but returned no digest to identify it' };
+ }
+ return { kind: 'update', digest: result.primaryDigest };
+}
diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx
index 9741fb4c..30b96a34 100644
--- a/docs/features/alerts-notifications.mdx
+++ b/docs/features/alerts-notifications.mdx
@@ -201,6 +201,7 @@ Every alert Sencho dispatches carries a category that you can filter on in the b
| `stack_taken_down` | Stack taken down | Stack taken down via the dashboard or API |
| `image_update_available` | Update available | Image-update poll found a newer digest |
| `node_update_available` | Node update | Sencho self-update available for this node |
+| `dev_build_update_available` | Dev build update | A node pinned to the `:dev` integration image has a newer build published |
| `image_update_applied` | Update applied | Manual or scheduled auto-update applied new images |
| `autoheal_triggered` | Auto-heal | Auto-heal restarted, failed to restart, or auto-disabled a policy |
| `monitor_alert` | Monitor alert | Per-stack threshold breach, host CPU/RAM/disk warning, or healthcheck failure |
diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx
index 8f4df16d..44ab46c7 100644
--- a/docs/features/fleet-view.mdx
+++ b/docs/features/fleet-view.mdx
@@ -93,6 +93,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi
| **Version badge** | The node's Sencho version in mono tabular numerals (e.g. `v0.76.3`). Hidden if the node cannot report a version. |
| **Update available** badge | Warning pill shown when a newer Sencho release is published for this node. |
| **Pinned** badge | Shown instead of **Update available** when the node's own Sencho image is pinned to a digest (or an unrecognised pin) and cannot be updated automatically from here. A tooltip explains why. |
+| **Integration image** badge | Warning pill shown, for every role, whenever the local node's Sencho image is pinned to the `sencho-dev` integration repository (see [Verifying images](/operations/verifying-images)). Persistent: it shows whether or not a newer build is currently available. |
| **Critical** badge | Destructive pill with a triangle icon, surfaced when the online node is above 90% CPU or 90% disk. |
| **Cordoned** badge | Warning pill with a Ban icon, surfaced when the node is cordoned. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. |
| **Networking** badge | Warning pill reading `Networking · exposed`, `Networking · drift`, or `Networking · unknown exposure`, shown when the node has a stack with published ports, detected network drift, or unresolved exposure. Click it to jump to that node's Networking page. |
@@ -101,6 +102,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi
| **Container stats grid** | Three cells: **Running** (active containers), **Stopped** (exited containers), **Stacks** (count, or `-` if the node has not reported). Hidden on offline nodes. |
| **CPU / RAM / Disk bars** | Each row shows the metric icon, the percent (CPU) or `used / total` (RAM, Disk), and a horizontal bar that tints amber at 60%, destructive at 80% (CPU/RAM), or amber at 75% / destructive at 90% (Disk). Hidden on offline nodes. |
| **Update to v…** button | Admin-only outline button that runs along the bottom of the card when an update is available. The label includes the latest version. |
+| **Update dev build** button | Admin-only, brand-colored button that replaces **Update to v…** when the local node is pinned to `sencho-dev:dev` and a newer integration build has been published. Pulls and recreates without rewriting the pinned tag. |
| **Stack details** trigger | Footer button that toggles the stack drill-down. The label carries the stack count for the node. |
Offline nodes render dimmed, with no stats grid, no usage bars, and no update affordance.
@@ -267,7 +269,7 @@ Four tiles tell you how the fleet is split across update states:
| Card | Meaning |
|------|---------|
| **Up to date** | Nodes whose current version equals the latest published Sencho release. |
-| **Available** | Nodes with a published update they are not yet running. |
+| **Available** | Nodes with a published update they are not yet running. Combines a pending stable release with a pending dev build on a `:dev`-pinned local node. |
| **Updating** | Nodes that are currently pulling and recreating with the new image. |
| **Failed** | Nodes whose most-recent update attempt did not succeed (failure or timeout). |
@@ -280,8 +282,8 @@ The table lists every registered node, filtered by the search box at the top. Co
| **Node** | Node name with a Monitor icon for local nodes and a Globe icon for remotes |
| **Type** | `local` or `remote` outline pill |
| **Current** | The node's reported Sencho version, in mono. Reads `unknown` if the node has not reported (offline, unreachable, or never connected). |
-| **Latest** | The newest published Sencho release. Highlighted when newer than Current. |
-| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. |
+| **Latest** | The newest published Sencho release. Highlighted when newer than Current. Reads **Integration build** on a `:dev`-pinned local node, since a dev build has no version number to compare against. |
+| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release or dev build is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. |
The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. See [Remote Updates · Reapply configuration](/features/remote-updates#reapply-configuration) for what reapply does and when to use it.
@@ -293,7 +295,7 @@ Skipped nodes show a **Skipped vX.Y.Z** badge and an **Unskip** button. Unskippi
### Changelog tab
-The **Changelog** tab shows the release notes for the latest published Sencho version, fetched from the GitHub Releases page. It displays the raw release notes alongside a link to view the full release on GitHub. If the release notes cannot be loaded, a message is shown in place.
+The **Changelog** tab shows the release notes for the latest published Sencho version, fetched from the GitHub Releases page. It displays the raw release notes alongside a link to view the full release on GitHub. If the release notes cannot be loaded, a message is shown in place. A pending dev build does not light the changelog indicator or appear here: it has no release notes to show.
### What happens when you click Update
diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx
index 0867bc3d..df662ea9 100644
--- a/docs/features/remote-updates.mdx
+++ b/docs/features/remote-updates.mdx
@@ -71,6 +71,8 @@ Eligible admins can also run the same procedure from the Compose editor: on Senc
Updating the gateway is special because the dashboard is hosted by the very container that is about to restart. Clicking **Update** on the local row, or **Update to vX.Y.Z** on the Local card, opens a confirmation dialog (kicker **LOCAL · UPDATE**, title **Update local node**, with **Cancel** and **Update & restart** buttons) before anything happens on disk. The body text depends on how the compose file pins the image: for a semver pin it names the exact rewrite (for example, "This install pins `saelix/sencho:0.94.1`. Updating rewrites it to `saelix/sencho:0.95.0`..."); for a floating tag it reads more generally ("Pulls Sencho v0.95.0 and restarts the server..."). Both variants end with the same note that the dashboard briefly disconnects and reconnects automatically.
+A node pinned to the `sencho-dev:dev` integration image gets a third variant instead: kicker **LOCAL · DEV UPDATE**, stating the `:dev` reference will be pulled and recreated without rewriting the pinned tag, plus a note that integration images are unsigned and carry no release attestations. Dev-build detection and its update action are local-node only: a remote node's dev-image status is not surfaced in Fleet, since a full compose image reference is not exposed for remotes (see [Pinned image tags](#pinned-image-tags)).
+
If the local node is running the Admiral **Hardened Build** image instead of a Community image, clicking **Update** here runs a different, entitlement-gated switch flow instead of the compose-repin steps below: it requires an authenticated browser session (not an API token) and can fail with its own codes (`entitlement_denied`, `preflight_mismatch`, `compose_unavailable`, `registry_access_unavailable`) surfaced on the same **Failed** badge. See [Licensing · Feature breakdown](/features/licensing#feature-breakdown) for what Hardened Build is.
@@ -97,7 +99,7 @@ If the new container does not come up within 5 minutes, the overlay surfaces a *
## Pinned image tags
-Fleet self-update respects how each node compose file declares the Sencho image. Semver pins are rewritten to the target release before recreate. Floating tags such as latest are pulled without changing the compose file. Digest pins and unresolved interpolated values block automatic updates; those rows show a **Pinned** badge instead of an **Update** button.
+Fleet self-update respects how each node compose file declares the Sencho image. Semver pins are rewritten to the target release before recreate. Floating tags such as `latest` and `sencho-dev:dev` are pulled without changing the compose file. Digest pins and unresolved interpolated values block automatic updates; those rows show a **Pinned** badge instead of an **Update** button. A reference to the `sencho-dev` repository is always treated as an integration image, whether pinned to `:dev`, the immutable `:dev-`, or a digest, and never compared against the stable release train.
The image pull always runs before any compose rewrite, so a failed pull never leaves the compose file half-updated.
diff --git a/docs/operations/upgrade.mdx b/docs/operations/upgrade.mdx
index 4d7f4ddc..7da91d47 100644
--- a/docs/operations/upgrade.mdx
+++ b/docs/operations/upgrade.mdx
@@ -78,6 +78,10 @@ Fleet can update semver pins directly from the [Node updates](/features/remote-u
Check [GitHub Releases](https://github.com/studio-saelix/sencho/releases) for available versions and changelogs.
+
+ Pinning to the `sencho-dev:dev` integration image instead of a release tag is a separate, unsigned track meant for pre-release testing, not this upgrade flow. See [Verifying images · Integration tag](/operations/verifying-images#integration-tag-post-merge-pre-release) and [Remote Updates · Updating the local (gateway) node](/features/remote-updates#updating-the-local-gateway-node) for how detection and the in-app update action work there, and how it differs from **Reapply configuration** (which recreates from the current Compose project without pulling a new image at all).
+
+
---
## Version policy
diff --git a/docs/operations/verifying-images.mdx b/docs/operations/verifying-images.mdx
index 7d94ace6..07d25f12 100644
--- a/docs/operations/verifying-images.mdx
+++ b/docs/operations/verifying-images.mdx
@@ -144,6 +144,8 @@ Every push to `main` publishes the current integration build so you can pull and
The `:dev` image is not for production. It is unsigned, is never published to Docker Hub, and does not carry the SBOM, provenance, or VEX attestations described above.
+An instance pinned to `:dev` shows a persistent "Integration image" marker on its Fleet card, and Sencho notifies you when a newer build has been published to the tag, with an in-app action to pull and recreate. Detection polls the tag on a fixed interval and reflects the newest build observed, so if several builds publish in quick succession you get one notification for the latest rather than one per build.
+
### Preview tags (pre-merge only)
Maintainers publish these from open PRs for external validation. They are unsigned and not for production.
diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx
index 7aededc2..4051cd69 100644
--- a/frontend/src/components/FleetView.tsx
+++ b/frontend/src/components/FleetView.tsx
@@ -400,6 +400,7 @@ export function FleetView({
composeImageRef={confirmStatus?.composeImageRef}
targetImageRef={confirmStatus?.targetImageRef}
targetVersion={confirmStatus?.latestVersion}
+ isDevImage={confirmStatus?.isDevImage}
/>
{NodeActionModals}
diff --git a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx
index dddf12e0..a5af3e0f 100644
--- a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx
+++ b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx
@@ -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.
);
+ } else if (isDevUpdate) {
+ body = (
+
+ Pulls ghcr.io/studio-saelix/sencho-dev:dev 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.
+
+ );
} else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) {
body = (
@@ -74,6 +88,11 @@ export function LocalUpdateConfirmDialog({
Reapply & restart
>
+ ) : isDevUpdate ? (
+ <>
+
+ Update & restart
+ >
) : (
<>
diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx
index ceaac74b..ab703480 100644
--- a/frontend/src/components/FleetView/NodeCard.tsx
+++ b/frontend/src/components/FleetView/NodeCard.tsx
@@ -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
)}
+ {updateStatus?.isDevImage && (
+
+ Integration image
+
+ )}
{isOnline && isCritical(node) && (
Critical
@@ -359,6 +364,27 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
)}
+ {/* 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 && (
+
+ onUpdate(node.id)}
+ disabled={updatingNodeId === node.id}
+ >
+ {updatingNodeId === node.id ? (
+ <> Triggering...>
+ ) : (
+ <> Update dev build>
+ )}
+
+
+ )}
+
{/* Offline placeholder */}
{!isOnline && (
diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx
index 839fa168..40ef4b60 100644
--- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx
+++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx
@@ -208,8 +208,15 @@ export function NodeUpdatesSheet({
}
};
- const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length;
- const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
+ // Dev availability tracks build freshness by digest, not the stable
+ // semver compare target, so it is counted separately from stableAvailable.
+ // A dev row's updateAvailable is always false (fleet.ts), so upToDate must
+ // exclude it too, or a dev-pinned node with a build available would render
+ // as "Up to date".
+ const stableAvailable = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
+ const devAvailable = updateStatuses.filter(s => s.devBuildUpdateAvailable && !s.updateStatus).length;
+ const totalAvailable = stableAvailable + devAvailable;
+ const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.devBuildUpdateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length;
const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length;
const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length;
const updatableRemoteCount = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length;
@@ -222,11 +229,11 @@ export function NodeUpdatesSheet({
const meta = updateStatuses.length === 0
? 'No nodes'
- : `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`;
+ : `${updateStatuses.length} nodes · ${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`;
const footerContext = updateStatuses.length === 0
? undefined
- : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
+ : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`);
const secondaryActions = isAdmin && updatableRemoteCount > 0
? [{
@@ -236,7 +243,9 @@ export function NodeUpdatesSheet({
}]
: undefined;
- const showChangelogDot = available > 0 && !hasSeenChangelog;
+ // A dev build has no release changelog entry, so only a stable release
+ // lights the changelog dot.
+ const showChangelogDot = stableAvailable > 0 && !hasSeenChangelog;
const showSkip = (s: NodeUpdateStatus) =>
s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion);
@@ -347,7 +356,7 @@ export function NodeUpdatesSheet({
-
{available}
+
{totalAvailable}
Available
@@ -405,7 +414,9 @@ export function NodeUpdatesSheet({
{formatVersion(s.version) ??
unknown }
- {formatVersion(s.latestVersion) ?? unknown }
+ {s.isDevImage
+ ? Integration build
+ : formatVersion(s.latestVersion) ?? unknown }
{s.updateStatus && (
@@ -421,7 +432,7 @@ export function NodeUpdatesSheet({
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
/>
)}
- {!s.updateStatus && !s.updateAvailable && !s.skipActive && (
+ {!s.updateStatus && !s.updateAvailable && !s.devBuildUpdateAvailable && !s.skipActive && (
Up to date
@@ -448,7 +459,7 @@ export function NodeUpdatesSheet({
className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40"
/>
)}
- {s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && (
+ {(s.updateAvailable || s.devBuildUpdateAvailable) && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && (
)}
- {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 && (
Available
diff --git a/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx b/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx
index a55f0ef0..0074968f 100644
--- a/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx
+++ b/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx
@@ -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 ? (
+
{kicker}
{title}
{children}
{confirmLabel}
@@ -47,6 +48,56 @@ describe('LocalUpdateConfirmDialog', () => {
expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument();
});
+ it('explains a dev-image update with the dev kicker and no-repin, unsigned-image copy', () => {
+ render(
+
,
+ );
+ expect(screen.getByText('LOCAL · DEV UPDATE')).toBeInTheDocument();
+ expect(screen.getByText(/ghcr\.io\/studio-saelix\/sencho-dev:dev/)).toBeInTheDocument();
+ expect(screen.getByText(/image reference is not rewritten/i)).toBeInTheDocument();
+ expect(screen.getByText(/unsigned/i)).toBeInTheDocument();
+ });
+
+ it('keeps the reapply kicker and copy for a dev image in reapply mode', () => {
+ render(
+
,
+ );
+ expect(screen.getByText('LOCAL · REAPPLY')).toBeInTheDocument();
+ expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument();
+ expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument();
+ });
+
+ it('uses the generic update copy and kicker when isDevImage is absent', () => {
+ render(
+
,
+ );
+ expect(screen.getByText('LOCAL · UPDATE')).toBeInTheDocument();
+ expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument();
+ });
+
it('explains local reapply without a version change or image rewrite', () => {
render(
{
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
});
+ it('shows the Integration image badge regardless of update availability', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Integration image')).toBeInTheDocument();
+ });
+
+ it('does not show the Integration image badge for a non-dev node', () => {
+ render( );
+ expect(screen.queryByText('Integration image')).not.toBeInTheDocument();
+ });
+
+ it('shows the dev-build update button for an admin when a dev build is available', async () => {
+ const onUpdate = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const button = screen.getByRole('button', { name: /Update dev build/ });
+ expect(button).toBeInTheDocument();
+ expect(screen.getByText('Integration image')).toBeInTheDocument();
+ await user.click(button);
+ expect(onUpdate).toHaveBeenCalledWith(2);
+ });
+
+ it('hides the dev-build update button for a non-admin', () => {
+ useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
+ render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument();
+ expect(screen.getByText('Integration image')).toBeInTheDocument();
+ });
+
+ it('hides the dev-build update button when no dev build is available', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument();
+ });
+
+ it('never shows both update buttons for a well-formed dev row (mutual exclusion by construction)', () => {
+ // The backend (fleet.ts) guarantees updateAvailable=false whenever isDevImage
+ // is true, so the two buttons' gating conditions can never both be satisfied
+ // for real data; the component intentionally adds no redundant isDevImage
+ // check to the stable button. This fixture reflects what the backend can
+ // actually send, not an artificial one.
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /Update dev build/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Update to/ })).not.toBeInTheDocument();
+ });
+
it('shows the networking signal badge and switches to the node on click', async () => {
const onOpenNetworking = vi.fn();
const user = userEvent.setup();
diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx
index 04c9654a..522123db 100644
--- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx
+++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) }));
@@ -303,6 +303,74 @@ describe('NodeUpdatesSheet', () => {
expect(screen.getByLabelText('Retry update')).toBeInTheDocument();
});
+ const DEV_STATUSES: NodeUpdateStatus[] = [
+ { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true },
+ { nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
+ ];
+
+ it('counts stable and dev availability separately in the summary and meta text', () => {
+ render( );
+ // 2 total available (1 stable + 1 dev).
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('does not light the changelog dot from a dev-only update', () => {
+ const devOnly: NodeUpdateStatus[] = [
+ { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true },
+ ];
+ render( );
+ const changelogTab = screen.getByRole('tab', { name: /Changelog/ });
+ expect(changelogTab.querySelector('.animate-ping')).toBeNull();
+ });
+
+ it('lights the changelog dot from a stable-only update', () => {
+ render( );
+ const changelogTab = screen.getByRole('tab', { name: /Changelog/ });
+ expect(changelogTab.querySelector('.animate-ping')).not.toBeNull();
+ });
+
+ it('does not show the per-row Up to date badge for a dev row with a build available', () => {
+ render( );
+ const row = screen.getByText('Local').closest('.grid') as HTMLElement;
+ // The summary section always renders a static "Up to date" category
+ // label regardless of count, so this must be scoped to the row itself.
+ expect(within(row).queryByText('Up to date')).not.toBeInTheDocument();
+ });
+
+ it('shows Integration build instead of a stable version in the Latest column for a dev row', () => {
+ render( );
+ expect(screen.getByText('Integration build')).toBeInTheDocument();
+ });
+
+ it('shows the Update action for an admin on a dev-available row', () => {
+ const triggerNodeUpdate = vi.fn();
+ render( );
+ const buttons = screen.getAllByRole('button', { name: /Update$/ });
+ // One for the dev row (nodeId 1), one for the stable row (nodeId 2).
+ expect(buttons).toHaveLength(2);
+ fireEvent.click(buttons[0]);
+ expect(triggerNodeUpdate).toHaveBeenCalledWith(1);
+ });
+
+ it('shows the read-only Available badge for a non-admin on a dev-available row', () => {
+ render( );
+ expect(screen.getAllByText('Available').length).toBeGreaterThan(0);
+ expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
+ });
+
+ it('excludes a dev row from Update all and Skip (both remain stable-only)', () => {
+ render( );
+ // Only the remote stable row (nodeId 2) counts toward Update all.
+ expect(screen.getByRole('button', { name: 'Update all (1)' })).toBeInTheDocument();
+ // Skip requires updateAvailable (stable), which is false for the dev row,
+ // so it never renders one, even though the stable "Edge" row legitimately
+ // gets one in this same fixture.
+ const devRow = screen.getByText('Local').closest('.grid') as HTMLElement;
+ expect(within(devRow).queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument();
+ const stableRow = screen.getByText('Edge').closest('.grid') as HTMLElement;
+ expect(within(stableRow).getByRole('button', { name: 'Skip' })).toBeInTheDocument();
+ });
+
it('toasts when a recheck is throttled by the server (rechecked:false)', async () => {
apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rechecked: false }) });
render( );
diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx
index 60971f18..0d3c5538 100644
--- a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx
+++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx
@@ -248,6 +248,56 @@ describe('useFleetUpdateStatus', () => {
vi.unstubAllGlobals();
});
+ it('confirmLocalUpdate omits targetVersion for a dev image even when latestVersion is a valid stable version', async () => {
+ const devStatuses: NodeUpdateStatus[] = [
+ { ...STATUSES[0], isDevImage: true },
+ STATUSES[1],
+ ];
+ apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses }));
+ const { result } = renderHook(() => useFleetUpdateStatus());
+ await act(async () => { await result.current.fetchUpdateStatus(); });
+
+ await act(async () => { await result.current.triggerNodeUpdate(1); });
+ expect(result.current.localUpdateConfirm).toBe(1);
+
+ apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
+ new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
+ )));
+
+ await act(async () => { await result.current.confirmLocalUpdate(); });
+
+ expect(apiFetchMock).toHaveBeenCalledWith(
+ '/fleet/nodes/1/update',
+ expect.objectContaining({ method: 'POST', localOnly: true }),
+ );
+ const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update');
+ expect(call![1]).not.toHaveProperty('body');
+ vi.unstubAllGlobals();
+ });
+
+ it('confirmLocalUpdate still omits targetVersion for a dev image with no valid latestVersion', async () => {
+ const devStatuses: NodeUpdateStatus[] = [
+ { ...STATUSES[0], isDevImage: true, latestVersion: null },
+ STATUSES[1],
+ ];
+ apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses }));
+ const { result } = renderHook(() => useFleetUpdateStatus());
+ await act(async () => { await result.current.fetchUpdateStatus(); });
+
+ await act(async () => { await result.current.triggerNodeUpdate(1); });
+ apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
+ new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
+ )));
+
+ await act(async () => { await result.current.confirmLocalUpdate(); });
+
+ const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update');
+ expect(call![1]).not.toHaveProperty('body');
+ vi.unstubAllGlobals();
+ });
+
it('dismisses the reconnecting overlay when the local update resolves failed', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
diff --git a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts
index a1779a8c..c00fe680 100644
--- a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts
+++ b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts
@@ -7,10 +7,18 @@ import { useComposeReapplyAction } from './useComposeReapplyAction';
/** POST body for an update trigger: forward the target release when it is a
* valid version so the receiving node can repin a semver pin to it; omit
- * otherwise so the backend falls back to its compare target. */
+ * otherwise so the backend falls back to its compare target.
+ *
+ * A dev image never gets a targetVersion, even when latestVersion is a
+ * valid stable release: latestVersion there is the latest STABLE release,
+ * unrelated to what a dev-channel update actually installs. The backend
+ * already ignores targetVersion safely for a floating pin (it only repins
+ * a semver-classified pin), so this is a copy-accuracy fix, not a safety
+ * fix: without it, the button label and confirm-dialog would claim a
+ * stable version number the update isn't installing. */
function updateRequestInit(status: NodeUpdateStatus | undefined): RequestInit & { localOnly: true } {
const base = { method: 'POST', localOnly: true } as const;
- return isValidVersion(status?.latestVersion)
+ return !status?.isDevImage && isValidVersion(status?.latestVersion)
? { ...base, body: JSON.stringify({ targetVersion: status!.latestVersion }) }
: base;
}
diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts
index 6b1ba012..565767de 100644
--- a/frontend/src/components/FleetView/types.ts
+++ b/frontend/src/components/FleetView/types.ts
@@ -77,6 +77,14 @@ export interface NodeUpdateStatus {
operationKind?: 'update' | 'reapply_configuration' | null;
/** True when this Compose-managed node can reapply its on-disk configuration. */
canReapplyCompose?: boolean;
+ /** True when the compose-declared image is any reference to the sencho-dev
+ * repository, including digest pins and dev-. Reflects what compose
+ * DECLARES, not necessarily what the container is currently running if
+ * compose was edited without a reapply. Local node only. */
+ isDevImage?: boolean;
+ /** True only when isDevImage is true, the pin is the exact floating :dev
+ * tag, and a newer build digest has been observed. Local node only. */
+ devBuildUpdateAvailable?: boolean;
}
export type ViewMode = 'grid' | 'topology';
diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx
index 7df312d4..052780da 100644
--- a/frontend/src/components/NotificationPanel.tsx
+++ b/frontend/src/components/NotificationPanel.tsx
@@ -153,7 +153,8 @@ export function NotificationPanel({
);
const hasNodeUpdateNotifs = useMemo(
- () => notifications.some((n) => !n.is_read && n.category === 'node_update_available'),
+ () => notifications.some((n) => !n.is_read
+ && (n.category === 'node_update_available' || n.category === 'dev_build_update_available')),
[notifications],
);
diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts
index d2485b7d..cd40ed78 100644
--- a/frontend/src/components/dashboard/types.ts
+++ b/frontend/src/components/dashboard/types.ts
@@ -69,6 +69,7 @@ export type NotificationCategory =
| 'health_gate_failed'
| 'rollback_generation_released'
| 'node_update_available'
+ | 'dev_build_update_available'
| 'system';
export interface NotificationItem {
diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx
index 6eefb642..cb6d8455 100644
--- a/frontend/src/components/mobile/MobileFleet.tsx
+++ b/frontend/src/components/mobile/MobileFleet.tsx
@@ -1,15 +1,19 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
-import { ChevronRight, Loader2 } from 'lucide-react';
+import { ChevronRight, FlaskConical, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { toast } from '@/components/ui/toast-store';
import { ConfirmModal } from '@/components/ui/modal';
+import { BusyButton } from '@/components/ui/busy-button';
import { formatBytes } from '@/lib/utils';
import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet';
-import type { FleetNode } from '@/components/FleetView/types';
+import { LocalUpdateConfirmDialog } from '@/components/FleetView/LocalUpdateConfirmDialog';
+import { ReconnectingOverlay } from '@/components/FleetView/ReconnectingOverlay';
+import { useFleetUpdateStatus } from '@/components/FleetView/hooks/useFleetUpdateStatus';
+import type { FleetNode, NodeUpdateStatus } from '@/components/FleetView/types';
import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui';
import type { Tone as UiTone } from './mobile-ui';
@@ -94,7 +98,7 @@ function StatCell({ label, value }: { label: string; value: string }) {
);
}
-function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) {
+function NodeCard({ node, isActive, isDevImage, onOpen }: { node: FleetNode; isActive: boolean; isDevImage: boolean; onOpen: () => void }) {
const tone = nodeTone(node);
const local = node.type === 'local';
const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online';
@@ -118,6 +122,11 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole
active
) : null}
+ {isDevImage ? (
+
+ integration
+
+ ) : null}
{node.cordoned ? 'cordoned' : stateLabel}
@@ -131,6 +140,25 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole
);
}
+// Sibling to NodeCard's outer , never nested inside it (a nested
+// 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 (
+ 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"
+ >
+ Update dev build
+
+ );
+}
+
// 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(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(
+ updateStatus.updateStatuses.map(s => [s.nodeId, s]),
+ );
+
+ const confirmStatus = updateStatus.localUpdateConfirm !== null
+ ? updateStatusByNodeId.get(updateStatus.localUpdateConfirm)
+ : undefined;
+
+ const overlays = (
+ <>
+ {updateStatus.reconnecting && (
+
+ )}
+ { 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 (
- setSelectedId(null)}
- onInspectNode={onInspectNode}
- onInspectStack={onInspectStack}
- onCordonChange={() => void refetch()}
- />
+ <>
+ 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 (
-
-
+ <>
+
+
-
-
-
-
0 ? `${avgCpu.toFixed(0)}%` : '--'} />
- 0 ? `${memPct.toFixed(0)}%` : '--'} />
+
+
+
+ 0 ? `${avgCpu.toFixed(0)}%` : '--'} />
+ 0 ? `${memPct.toFixed(0)}%` : '--'} />
+
+
+ {loading && nodes.length === 0 ? (
+
+
+
+ ) : nodes.length === 0 ? (
+
No nodes configured.
+ ) : (
+
+ {nodes.map(node => {
+ const nodeUpdateStatus = updateStatusByNodeId.get(node.id);
+ return (
+
+ setSelectedId(node.id)}
+ />
+ {nodeUpdateStatus?.devBuildUpdateAvailable && isAdmin && (
+
+ )}
+
+ );
+ })}
+
+ )}
-
- {loading && nodes.length === 0 ? (
-
-
-
- ) : nodes.length === 0 ? (
- No nodes configured.
- ) : (
-
- {nodes.map(node => (
- setSelectedId(node.id)}
- />
- ))}
-
- )}
-
+ {overlays}
+ >
);
}
diff --git a/frontend/src/components/mobile/__tests__/MobileFleet.test.tsx b/frontend/src/components/mobile/__tests__/MobileFleet.test.tsx
new file mode 100644
index 00000000..62f9014d
--- /dev/null
+++ b/frontend/src/components/mobile/__tests__/MobileFleet.test.tsx
@@ -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
(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 {
+ 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 {
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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 cannot legally contain another ; 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+ 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();
+ });
+});
diff --git a/frontend/src/lib/__tests__/notificationVisibility.test.ts b/frontend/src/lib/__tests__/notificationVisibility.test.ts
index b380c667..4804da6f 100644
--- a/frontend/src/lib/__tests__/notificationVisibility.test.ts
+++ b/frontend/src/lib/__tests__/notificationVisibility.test.ts
@@ -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);
diff --git a/frontend/src/lib/muteRules.test.ts b/frontend/src/lib/muteRules.test.ts
index 47dfb0e3..e15dae28 100644
--- a/frontend/src/lib/muteRules.test.ts
+++ b/frontend/src/lib/muteRules.test.ts
@@ -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',
+ ]);
+ });
+});
diff --git a/frontend/src/lib/muteRules.ts b/frontend/src/lib/muteRules.ts
index 41051b4e..d0a65c06 100644
--- a/frontend/src/lib/muteRules.ts
+++ b/frontend/src/lib/muteRules.ts
@@ -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'],
};
}
diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts
index e615163d..0e964cc3 100644
--- a/frontend/src/lib/notificationCategories.ts
+++ b/frontend/src/lib/notificationCategories.ts
@@ -19,5 +19,6 @@ export const CATEGORY_LABELS: Record = {
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',
};