diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index 7be2aa82..bdfd9c55 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -6,8 +6,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── -const { mockGetAuthForRegistry } = vi.hoisted(() => ({ +const { + mockGetAuthForRegistry, + mockGetStackUpdateStatus, mockUpsertStackUpdateStatus, mockClearStackUpdateStatus, + mockGetSystemState, mockSetSystemState, mockAddNotificationHistory, + mockDispatchAlert, + mockGetStacks, mockGetStackContent, mockGetEnvContent, + mockGetAllContainers, +} = vi.hoisted(() => ({ mockGetAuthForRegistry: vi.fn().mockResolvedValue(null), + mockGetStackUpdateStatus: vi.fn().mockReturnValue({}), + mockUpsertStackUpdateStatus: vi.fn(), + mockClearStackUpdateStatus: vi.fn(), + mockGetSystemState: vi.fn().mockReturnValue('1'), // default: backfilled + mockSetSystemState: vi.fn(), + mockAddNotificationHistory: vi.fn(), + mockDispatchAlert: vi.fn().mockResolvedValue(undefined), + mockGetStacks: vi.fn().mockResolvedValue([]), + mockGetStackContent: vi.fn().mockResolvedValue(''), + mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')), + mockGetAllContainers: vi.fn().mockResolvedValue([]), })); vi.mock('../services/RegistryService', () => ({ @@ -23,19 +41,40 @@ vi.mock('../services/DatabaseService', () => ({ getInstance: () => ({ getGlobalSettings: () => ({ developer_mode: '0' }), getNodes: () => [], - upsertStackUpdateStatus: vi.fn(), - getStackUpdateStatus: () => ({}), - clearStackUpdateStatus: vi.fn(), + upsertStackUpdateStatus: mockUpsertStackUpdateStatus, + getStackUpdateStatus: mockGetStackUpdateStatus, + clearStackUpdateStatus: mockClearStackUpdateStatus, + getSystemState: mockGetSystemState, + setSystemState: mockSetSystemState, + addNotificationHistory: mockAddNotificationHistory, + }), + }, +})); + +vi.mock('../services/NotificationService', () => ({ + NotificationService: { + getInstance: () => ({ + dispatchAlert: mockDispatchAlert, }), }, })); vi.mock('../services/FileSystemService', () => ({ - FileSystemService: { getInstance: () => ({}) }, + FileSystemService: { + getInstance: () => ({ + getStacks: mockGetStacks, + getStackContent: mockGetStackContent, + getEnvContent: mockGetEnvContent, + }), + }, })); vi.mock('../services/DockerController', () => ({ - default: { getInstance: () => ({}) }, + default: { + getInstance: () => ({ + getAllContainers: mockGetAllContainers, + }), + }, })); vi.mock('../services/NodeRegistry', () => ({ @@ -298,3 +337,108 @@ services: expect(extractImagesFromCompose(yaml, {})).toEqual([]); }); }); + +// ── Notification dispatch on state transitions ──────────────────────── + +describe('ImageUpdateService - notification dispatch', () => { + const COMPOSE = ` +services: + app: + image: nginx:latest +`; + + const fakeDb = () => ({ + getStackUpdateStatus: mockGetStackUpdateStatus, + upsertStackUpdateStatus: mockUpsertStackUpdateStatus, + clearStackUpdateStatus: mockClearStackUpdateStatus, + getSystemState: mockGetSystemState, + setSystemState: mockSetSystemState, + addNotificationHistory: mockAddNotificationHistory, + }); + + beforeEach(() => { + vi.clearAllMocks(); + (ImageUpdateService as any).instance = undefined; + // Default: backfill complete so transition logic applies normally. + mockGetSystemState.mockReturnValue('1'); + mockGetStacks.mockResolvedValue(['stackA']); + mockGetStackContent.mockResolvedValue(COMPOSE); + mockGetAllContainers.mockResolvedValue([]); + }); + + /** + * Stubs the private checkImage method so tests don't need to mock + * the entire registry-fetch stack. + */ + function stubCheckImage(service: ImageUpdateService, hasUpdate: boolean) { + (service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate }); + } + + it('dispatches notification when a stack transitions from no-update to has-update', async () => { + mockGetStackUpdateStatus.mockReturnValue({ stackA: false }); + const service = ImageUpdateService.getInstance(); + stubCheckImage(service, true); + + await (service as any).checkNode(1, 'local', fakeDb()); + + expect(mockDispatchAlert).toHaveBeenCalledTimes(1); + expect(mockDispatchAlert).toHaveBeenCalledWith( + 'info', + expect.stringContaining('stackA'), + 'stackA', + ); + expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(1, 'stackA', true, expect.any(Number)); + }); + + it('does not re-fire notification for a stack already known to have updates', async () => { + mockGetStackUpdateStatus.mockReturnValue({ stackA: true }); + const service = ImageUpdateService.getInstance(); + stubCheckImage(service, true); + + await (service as any).checkNode(1, 'local', fakeDb()); + + expect(mockDispatchAlert).not.toHaveBeenCalled(); + }); + + it('backfills catch-up notifications once for pre-existing has_update rows', async () => { + // Simulate a stale DB: two stacks already have has_update = true, + // but the backfill flag is not set. + mockGetSystemState.mockReturnValue(null); + mockGetStacks.mockResolvedValue(['stackA', 'stackB']); + mockGetStackUpdateStatus.mockReturnValue({ stackA: true, stackB: true }); + const service = ImageUpdateService.getInstance(); + stubCheckImage(service, true); + + await (service as any).checkNode(1, 'local', fakeDb()); + + expect(mockDispatchAlert).toHaveBeenCalledTimes(2); + const dispatched = mockDispatchAlert.mock.calls.map(call => call[2]); + expect(dispatched).toEqual(expect.arrayContaining(['stackA', 'stackB'])); + expect(mockSetSystemState).toHaveBeenCalledWith('image_update_notifications_backfilled', '1'); + + // Second run with backfill flag set and the same state: no further notifications. + vi.clearAllMocks(); + mockGetSystemState.mockReturnValue('1'); + mockGetStacks.mockResolvedValue(['stackA', 'stackB']); + mockGetStackUpdateStatus.mockReturnValue({ stackA: true, stackB: true }); + stubCheckImage(service, true); + + await (service as any).checkNode(1, 'local', fakeDb()); + + expect(mockDispatchAlert).not.toHaveBeenCalled(); + }); + + it('surfaces dispatch failures as an error entry in notification history', async () => { + mockGetStackUpdateStatus.mockReturnValue({ stackA: false }); + mockDispatchAlert.mockRejectedValueOnce(new Error('webhook timeout')); + const service = ImageUpdateService.getInstance(); + stubCheckImage(service, true); + + await (service as any).checkNode(1, 'local', fakeDb()); + + expect(mockAddNotificationHistory).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('webhook timeout'), + })); + }); +}); diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index bba7d14a..1aac13de 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -15,6 +15,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockCurrentLoad, mockMem, mockFsSize, mockExecAsync, mockFetchLatestSenchoVersion, + mockGetSenchoVersion, } = vi.hoisted(() => ({ mockGetGlobalSettings: vi.fn().mockReturnValue({}), mockGetNodes: vi.fn().mockReturnValue([]), @@ -36,6 +37,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockFsSize: vi.fn().mockResolvedValue([{ mount: '/', use: 30 }]), mockExecAsync: vi.fn().mockResolvedValue({ stdout: '' }), mockFetchLatestSenchoVersion: vi.fn().mockRejectedValue(new Error('not configured')), + mockGetSenchoVersion: vi.fn().mockReturnValue(null), })); vi.mock('../services/DatabaseService', () => ({ @@ -70,6 +72,15 @@ vi.mock('../utils/version-check', () => ({ fetchLatestSenchoVersion: (...args: unknown[]) => mockFetchLatestSenchoVersion(...args), })); +vi.mock('../services/CapabilityRegistry', async () => { + const semver = await import('semver'); + return { + isValidVersion: (v: string | null | undefined): v is string => + !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.default.valid(v), + getSenchoVersion: () => mockGetSenchoVersion(), + }; +}); + vi.mock('../services/NotificationService', () => ({ NotificationService: { getInstance: () => ({ @@ -599,8 +610,7 @@ describe('MonitorService - restart_count metric', () => { describe('MonitorService - Sencho version check', () => { it('dispatches notification when newer version available', async () => { - // Set current version - process.env.npm_package_version = '0.45.0'; + mockGetSenchoVersion.mockReturnValue('0.45.0'); mockFetchLatestSenchoVersion.mockResolvedValue('0.46.0'); mockGetSystemState.mockReturnValue(null); // No previous notification mockGetGlobalSettings.mockReturnValue({}); @@ -613,11 +623,13 @@ describe('MonitorService - Sencho version check', () => { await (svc as any).evaluate(); expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('0.46.0')); + // Message must include the real running version, not "0.0.0". + expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('currently running 0.45.0')); expect(mockSetSystemState).toHaveBeenCalledWith('last_sencho_update_notified_version', '0.46.0'); }); it('does not re-notify for the same version', async () => { - process.env.npm_package_version = '0.45.0'; + mockGetSenchoVersion.mockReturnValue('0.45.0'); mockFetchLatestSenchoVersion.mockResolvedValue('0.46.0'); mockGetSystemState.mockReturnValue('0.46.0'); // Already notified for this version mockGetGlobalSettings.mockReturnValue({}); @@ -632,7 +644,7 @@ describe('MonitorService - Sencho version check', () => { }); it('handles version check failure gracefully', async () => { - process.env.npm_package_version = '0.45.0'; + mockGetSenchoVersion.mockReturnValue('0.45.0'); mockFetchLatestSenchoVersion.mockRejectedValue(new Error('Network down')); mockGetGlobalSettings.mockReturnValue({}); mockGetNodes.mockReturnValue([]); @@ -647,7 +659,7 @@ describe('MonitorService - Sencho version check', () => { }); it('respects the 6-hour cooldown interval', async () => { - process.env.npm_package_version = '0.45.0'; + mockGetSenchoVersion.mockReturnValue('0.45.0'); mockFetchLatestSenchoVersion.mockResolvedValue('0.46.0'); mockGetSystemState.mockReturnValue(null); mockGetGlobalSettings.mockReturnValue({}); @@ -662,4 +674,21 @@ describe('MonitorService - Sencho version check', () => { // fetchLatestSenchoVersion should not have been called since we're within cooldown expect(mockFetchLatestSenchoVersion).not.toHaveBeenCalled(); }); + + it('skips version check when getSenchoVersion returns null', async () => { + // Simulates the production-Docker scenario that previously leaked "0.0.0" + mockGetSenchoVersion.mockReturnValue(null); + mockFetchLatestSenchoVersion.mockResolvedValue('0.46.0'); + mockGetSystemState.mockReturnValue(null); + mockGetGlobalSettings.mockReturnValue({}); + mockGetNodes.mockReturnValue([]); + mockGetStackAlerts.mockReturnValue([]); + + const svc = MonitorService.getInstance(); + (svc as any).lastVersionCheckAt = 0; + await (svc as any).evaluate(); + + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('0.46.0')); + expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything()); + }); }); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index e257012b..79f1cc6e 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -9,6 +9,9 @@ import { RegistryService } from './RegistryService'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import { isDebugEnabled } from '../utils/debug'; +import { getErrorMessage } from '../utils/errors'; + +const BACKFILL_KEY = 'image_update_notifications_backfilled'; // ─── Image ref parsing ──────────────────────────────────────────────────────── @@ -377,6 +380,10 @@ export class ImageUpdateService { // Read previous state to detect new updates for notifications const previousState = db.getStackUpdateStatus(nodeId); + // One-time backfill: pre-existing has_update rows predate the notification pipeline; + // treat them as unnotified on first run so users get a catch-up entry per affected stack. + const isBackfilled = db.getSystemState(BACKFILL_KEY) === '1'; + // Write status for ALL stacks (including those with no pullable images) const now = Date.now(); let updatesFound = 0; @@ -386,7 +393,7 @@ export class ImageUpdateService { if (hasUpdate) { updatesFound++; // Notify only on state transition: was false/absent, now true - if (!previousState[stackName]) { + if (!isBackfilled || !previousState[stackName]) { newlyUpdated.push(stackName); } } @@ -405,10 +412,25 @@ export class ImageUpdateService { ); } catch (e) { console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e); + // Direct DB write to avoid recursing through dispatchAlert if it is what failed. + try { + db.addNotificationHistory({ + level: 'error', + message: `[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`, + timestamp: Date.now(), + }); + } catch (dbErr) { + console.error('[ImageUpdateService] Failed to record dispatch error:', dbErr); + } } } } + // Mark the backfill flag after the first run so future checks use strict transitions. + if (!isBackfilled) { + db.setSystemState(BACKFILL_KEY, '1'); + } + console.log(`[ImageUpdateService] Node ${nodeId}: checked ${allImages.size} image(s), ${updatesFound} stack(s) with updates`); // Prune stale entries for stacks no longer on disk (reuse previousState to avoid extra DB read) diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 4710f2d9..8baabea3 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -5,7 +5,7 @@ import semver from 'semver'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; import { NotificationService } from './NotificationService'; -import { isValidVersion } from './CapabilityRegistry'; +import { isValidVersion, getSenchoVersion } from './CapabilityRegistry'; import { fetchLatestSenchoVersion } from '../utils/version-check'; import { isDebugEnabled } from '../utils/debug'; @@ -266,7 +266,9 @@ export class MonitorService { if (Date.now() - this.lastVersionCheckAt > MonitorService.VERSION_CHECK_INTERVAL_MS) { this.lastVersionCheckAt = Date.now(); try { - const currentVersion = process.env.npm_package_version || '0.0.0'; + // Resolve from the packaged manifest: process.env.npm_package_version is + // only set by npm scripts, so it is undefined in Docker (node dist/index.js). + const currentVersion = getSenchoVersion(); const latest = await fetchLatestSenchoVersion(); if (isValidVersion(latest) && isValidVersion(currentVersion) && semver.gt(latest, currentVersion)) { const db = DatabaseService.getInstance(); @@ -278,6 +280,8 @@ export class MonitorService { `Sencho ${latest} is available (currently running ${currentVersion}). Visit the Fleet dashboard to update.`); db.setSystemState(stateKey, latest); } + } else if (isDebugEnabled() && !isValidVersion(currentVersion)) { + console.debug('[Monitor:diag] Sencho version unresolvable; skipping update notification'); } } catch (e) { // Network errors are expected; do not spam logs diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 74819fb3..973c5194 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -146,11 +146,21 @@ Sencho can notify you when software updates are available, both for Sencho itsel ### Sencho version updates -When a newer version of Sencho is published, an informational notification is dispatched through your configured channels. This check runs periodically and only notifies once per new version. After you update, the cycle resets and you will be notified when the next release is available. +When a newer version of Sencho is published, an informational notification is dispatched through your configured channels. Each Sencho instance runs its own version check roughly once every 6 hours and notifies exactly once per new version, so remote nodes self-report their own availability alerts. After you update, the cycle resets and you will be notified when the next release becomes available. + +The notification message includes the version you are running and the version that was detected, for example: + +``` +Sencho 0.47.0 is available (currently running 0.46.16). Visit the Fleet dashboard to update. +``` ### Stack image updates -When the periodic image check (every 6 hours) detects that a stack has new upstream images available, a notification is dispatched for each affected stack. Notifications are sent only on state transitions: you will be notified once when a new update appears, not on every check cycle. After you update the stack, the status resets. +When the periodic image check (every 6 hours) detects that a stack has new upstream images available, a notification is dispatched for each affected stack. Notifications are sent only on state transitions: you will be notified once when an update first appears, not on every check cycle. After you update the stack, the status resets so a future release can notify again. + + + If you upgraded to a Sencho version that added image update notifications and you already had stacks flagged as having updates, the first check after the upgrade sends one catch-up notification per affected stack so the in-app bell stays in sync with the blue update indicator. + Both notification types use the same channel routing as alerts: if notification routes are configured for a stack, those channels receive the message; otherwise, global notification channels are used as a fallback. @@ -173,3 +183,13 @@ Both notification types use the same channel routing as alerts: if notification ### Delete confirmation dialog Deleting an alert rule now requires confirmation. Click the trash icon next to a rule, then confirm in the dialog that appears. + +### Version notification shows "currently running 0.0.0" + +This affected older Sencho builds that read the running version from an environment variable which was not always populated. Update to the current release; the running version is now resolved from the packaged manifest and the notification will report it correctly the next time a new release is detected. + +### A stack shows the blue update indicator but no notification was received + +If the blue indicator was already visible before you upgraded to a Sencho version that supports image update notifications, the first image check after the upgrade sends one catch-up notification per affected stack. Subsequent checks notify only when a stack newly transitions from "no updates" to "updates available". + +If the indicator appeared after the upgrade but no notification followed, open the notification bell. Dispatch failures (for example, a misconfigured Discord or Slack webhook) are logged as error entries in the bell so they are visible without tailing logs. Confirm at least one channel is enabled in **Settings > Notifications**; the in-app bell receives all notifications regardless of external channel configuration.