mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
feat: add node-scoped opt-out for image update detection (#1715)
* feat: add node-scoped opt-out for image update detection Operators who use an external update authority can disable Sencho registry polling per node without losing explicit stack Update, pull, or redeploy. * test: fix mocks and lint for image-update checks opt-out Scheduler tests need isChecksEnabled on the ImageUpdateService mock, and the UpdatesSection older-node fixture must not leave an unused binding. * fix: gate update-preview and recheck when detection is off Anatomy was still calling stack update-preview (and contacting registries) while checks were disabled. Short-circuit those routes and skip recheckStack writes so disabled nodes stay quiet until detection is re-enabled.
This commit is contained in:
@@ -135,15 +135,20 @@ export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatu
|
||||
|
||||
if (!cadence) return null;
|
||||
|
||||
const checksOff = cadence.enabled === false;
|
||||
const lastChecked = cadence.lastCheckedAt != null ? formatTimeAgo(cadence.lastCheckedAt) : 'never';
|
||||
const nextCheck = cadence.checking
|
||||
? 'checking now'
|
||||
: cadence.nextCheckAt != null
|
||||
? formatRelative(cadence.nextCheckAt)
|
||||
: 'not scheduled';
|
||||
const cooldown = cooling
|
||||
? `Recheck available in ${Math.ceil(remainingMs / 1000)}s`
|
||||
: 'Recheck ready';
|
||||
const nextCheck = checksOff
|
||||
? 'disabled'
|
||||
: cadence.checking
|
||||
? 'checking now'
|
||||
: cadence.nextCheckAt != null
|
||||
? formatRelative(cadence.nextCheckAt)
|
||||
: 'not scheduled';
|
||||
const cooldown = checksOff
|
||||
? 'Detection off'
|
||||
: cooling
|
||||
? `Recheck available in ${Math.ceil(remainingMs / 1000)}s`
|
||||
: 'Recheck ready';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-stat-subtitle/90 ${className ?? ''}`}>
|
||||
@@ -465,6 +470,7 @@ function ReadinessHero({
|
||||
refreshing,
|
||||
onRefresh,
|
||||
unresolvedChecks = false,
|
||||
detectionDisabled = false,
|
||||
}: {
|
||||
total: number;
|
||||
ready: number;
|
||||
@@ -472,12 +478,15 @@ function ReadinessHero({
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
unresolvedChecks?: boolean;
|
||||
detectionDisabled?: boolean;
|
||||
}) {
|
||||
const headline = total === 0
|
||||
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
|
||||
: total === 1
|
||||
? '1 update pending'
|
||||
: `${total} updates pending`;
|
||||
const headline = detectionDisabled
|
||||
? 'Image update detection disabled'
|
||||
: total === 0
|
||||
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
|
||||
: total === 1
|
||||
? '1 update pending'
|
||||
: `${total} updates pending`;
|
||||
const acrossNodes = nodeCount > 1
|
||||
? ` across ${nodeCount} nodes`
|
||||
: nodeCount === 1
|
||||
@@ -1269,19 +1278,25 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker="fleet · updates"
|
||||
state={total === 0
|
||||
? (checkFailures.length > 0 ? 'No verified updates' : 'Up to date')
|
||||
: `${total} pending`}
|
||||
stateTone={total === 0 && checkFailures.length === 0 ? 'success' : 'warning'}
|
||||
live={total > 0}
|
||||
meta={total > 0
|
||||
? `${ready} ready · ${total - ready} in review`
|
||||
: (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')}
|
||||
state={cadence?.enabled === false
|
||||
? 'Disabled'
|
||||
: total === 0
|
||||
? (checkFailures.length > 0 ? 'No verified updates' : 'Up to date')
|
||||
: `${total} pending`}
|
||||
stateTone={cadence?.enabled === false
|
||||
? 'brand'
|
||||
: total === 0 && checkFailures.length === 0 ? 'success' : 'warning'}
|
||||
live={total > 0 && cadence?.enabled !== false}
|
||||
meta={cadence?.enabled === false
|
||||
? 'image update detection off'
|
||||
: total > 0
|
||||
? `${ready} ready · ${total - ready} in review`
|
||||
: (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')}
|
||||
right={headerActions}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4 [&>*+*]:mt-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing} aria-label="Recheck registries" className="gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || cadence?.enabled === false} aria-label="Recheck registries" className="gap-1.5">
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} strokeWidth={1.5} aria-hidden="true" />
|
||||
Recheck
|
||||
</Button>
|
||||
@@ -1300,12 +1315,16 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16 text-center">
|
||||
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="font-display italic text-xl text-stat-value">
|
||||
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
||||
{cadence?.enabled === false
|
||||
? 'Detection disabled'
|
||||
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">
|
||||
{checkFailures.length > 0
|
||||
? 'Review the unresolved checks above, then recheck.'
|
||||
: 'Sencho rechecks registries on the configured interval.'}
|
||||
{cadence?.enabled === false
|
||||
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
|
||||
: checkFailures.length > 0
|
||||
? 'Review the unresolved checks above, then recheck.'
|
||||
: 'Sencho rechecks registries on the configured interval.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1333,6 +1352,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
unresolvedChecks={checkFailures.length > 0}
|
||||
detectionDisabled={cadence?.enabled === false}
|
||||
/>
|
||||
|
||||
<CadenceStrip cadence={cadence} className="-mt-3 pl-7" />
|
||||
@@ -1353,12 +1373,16 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16">
|
||||
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="font-display italic text-xl text-stat-value">
|
||||
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
||||
{cadence?.enabled === false
|
||||
? 'Detection disabled'
|
||||
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">
|
||||
{checkFailures.length > 0
|
||||
? 'Review the unresolved checks above, then recheck.'
|
||||
: 'Sencho rechecks registries on the configured interval.'}
|
||||
{cadence?.enabled === false
|
||||
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
|
||||
: checkFailures.length > 0
|
||||
? 'Review the unresolved checks above, then recheck.'
|
||||
: 'Sencho rechecks registries on the configured interval.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -68,11 +68,45 @@ export function UpdatesSection() {
|
||||
const activeNodeIdRef = useRef(activeNode?.id ?? null);
|
||||
activeNodeIdRef.current = activeNode?.id ?? null;
|
||||
|
||||
const checksEnabled = status?.enabled ?? true;
|
||||
const nodeSupportsEnabledSetting = status !== null && status.enabled !== undefined;
|
||||
const cadenceLocked = !checksEnabled || readOnly || isSaving;
|
||||
|
||||
// Derive toggle state from the current status. When the field is missing
|
||||
// (older remote node) the toggle is disabled with a helpful message.
|
||||
const sidebarIndicators = status?.sidebarIndicators ?? false;
|
||||
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
|
||||
|
||||
const handleChecksEnabledChange = useCallback(async (next: boolean) => {
|
||||
const targetNodeId = activeNodeIdRef.current;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/enabled', {
|
||||
method: 'PUT',
|
||||
nodeId: targetNodeId ?? null,
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
const data = await res.json() as ImageUpdateStatus;
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
setStatus(data);
|
||||
setUiMode(data.mode);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: ['image_update_checks_enabled'] },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
toast.error((e as Error)?.message || 'Failed to update image update checks setting.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
|
||||
const targetNodeId = activeNodeIdRef.current;
|
||||
setIsSaving(true);
|
||||
@@ -87,7 +121,7 @@ export function UpdatesSection() {
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
// Guard: if the active node changed while the PATCH was in flight,
|
||||
// discard the response — it belongs to a different node.
|
||||
// discard the response; it belongs to a different node.
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
@@ -109,7 +143,11 @@ export function UpdatesSection() {
|
||||
useMastheadStats(
|
||||
isLoading || intervalMinutes == null
|
||||
? null
|
||||
: [{ label: 'INTERVAL', value: formatIntervalLabel(intervalMinutes), tone: 'value' }],
|
||||
: [{
|
||||
label: checksEnabled ? 'INTERVAL' : 'CHECKS',
|
||||
value: checksEnabled ? formatIntervalLabel(intervalMinutes) : 'Off',
|
||||
tone: 'value',
|
||||
}],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,7 +246,7 @@ export function UpdatesSection() {
|
||||
const cronFieldError = getCronFieldError(draftCron);
|
||||
const cronDescription = cronTrimmed.length > 0 ? getCronDescription(draftCron) : '';
|
||||
const hasDescriptionError = cronTrimmed.length > 0 && cronDescription === 'Invalid expression';
|
||||
const canSaveCron = cronTrimmed.length > 0 && !cronFieldError && !hasDescriptionError && !isSaving;
|
||||
const canSaveCron = cronTrimmed.length > 0 && !cronFieldError && !hasDescriptionError && !isSaving && checksEnabled;
|
||||
|
||||
const handleSaveCron = useCallback(async () => {
|
||||
if (!canSaveCron || intervalMinutes == null) return;
|
||||
@@ -251,15 +289,36 @@ export function UpdatesSection() {
|
||||
: INTERVAL_PRESETS;
|
||||
|
||||
const lastChecked = status?.lastCheckedAt != null ? formatTimeAgo(status.lastCheckedAt) : 'never';
|
||||
const nextCheck = status?.checking
|
||||
? 'checking now'
|
||||
: status?.nextCheckAt != null
|
||||
? `in ${formatTimeUntil(status.nextCheckAt)}`
|
||||
: 'not scheduled';
|
||||
const nextCheck = status?.enabled === false
|
||||
? 'disabled'
|
||||
: status?.checking
|
||||
? 'checking now'
|
||||
: status?.nextCheckAt != null
|
||||
? `in ${formatTimeUntil(status.nextCheckAt)}`
|
||||
: 'not scheduled';
|
||||
|
||||
const enableHelper = status !== null && status.enabled === undefined
|
||||
? 'This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it.'
|
||||
: checksEnabled
|
||||
? 'When on, Sencho polls registries on a schedule, raises update notifications, and feeds Home, sidebar, Anatomy, and Fleet Readiness. Turn off when another tool is the update authority for this node.'
|
||||
: 'Image update detection is off for this node. Scheduled registry checks and update notifications are stopped. Explicit stack Update, pull, and redeploy actions remain available.';
|
||||
|
||||
return (
|
||||
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
||||
<SettingsSection title="Registry checks" kicker="node-scoped">
|
||||
<SettingsField
|
||||
label="Enable image update checks"
|
||||
helper={enableHelper}
|
||||
htmlFor="image-checks-enabled-toggle"
|
||||
>
|
||||
<TogglePill
|
||||
id="image-checks-enabled-toggle"
|
||||
checked={checksEnabled && nodeSupportsEnabledSetting}
|
||||
onChange={handleChecksEnabledChange}
|
||||
disabled={status === null || !nodeSupportsEnabledSetting || readOnly || isSaving}
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Check registries for image updates every"
|
||||
helper="Sencho checks registries to detect available image updates and raise notifications. Choose a fixed interval, or set a cron expression for precise scheduling. Cron expressions run in the node's local timezone. Each node checks on its own schedule."
|
||||
@@ -270,6 +329,7 @@ export function UpdatesSection() {
|
||||
onChange={handleModeChange}
|
||||
ariaLabel="Image check scheduling mode"
|
||||
className="self-start"
|
||||
disabled={cadenceLocked || intervalMinutes == null}
|
||||
options={[
|
||||
{ value: 'interval', label: 'Interval' },
|
||||
{ value: 'cron', label: 'Cron' },
|
||||
@@ -280,7 +340,7 @@ export function UpdatesSection() {
|
||||
<Select
|
||||
value={intervalMinutes != null ? String(intervalMinutes) : undefined}
|
||||
onValueChange={handleIntervalChange}
|
||||
disabled={readOnly || isSaving || intervalMinutes == null}
|
||||
disabled={cadenceLocked || intervalMinutes == null}
|
||||
>
|
||||
<SelectTrigger className="w-44" aria-label="Image update check interval">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
@@ -301,7 +361,7 @@ export function UpdatesSection() {
|
||||
placeholder="0 3 * * 1"
|
||||
value={draftCron}
|
||||
onChange={e => { setDraftCron(e.target.value); setSaveError(null); }}
|
||||
disabled={readOnly || isSaving}
|
||||
disabled={cadenceLocked}
|
||||
/>
|
||||
<SettingsPrimaryButton
|
||||
disabled={!canSaveCron}
|
||||
@@ -327,24 +387,26 @@ export function UpdatesSection() {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Sidebar" kicker="node-scoped">
|
||||
<SettingsField
|
||||
label="Show update status in sidebar"
|
||||
helper={
|
||||
status !== null && status.sidebarIndicators === undefined
|
||||
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
|
||||
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
|
||||
}
|
||||
htmlFor="sidebar-indicators-toggle"
|
||||
>
|
||||
<TogglePill
|
||||
id="sidebar-indicators-toggle"
|
||||
checked={sidebarIndicators}
|
||||
onChange={handleSidebarIndicatorsChange}
|
||||
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
{checksEnabled && (
|
||||
<SettingsSection title="Sidebar" kicker="node-scoped">
|
||||
<SettingsField
|
||||
label="Show update status in sidebar"
|
||||
helper={
|
||||
status !== null && status.sidebarIndicators === undefined
|
||||
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
|
||||
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
|
||||
}
|
||||
htmlFor="sidebar-indicators-toggle"
|
||||
>
|
||||
<TogglePill
|
||||
id="sidebar-indicators-toggle"
|
||||
checked={sidebarIndicators}
|
||||
onChange={handleSidebarIndicatorsChange}
|
||||
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ const STATUS = {
|
||||
manualCooldownRemainingMs: 0,
|
||||
mode: 'interval' as const,
|
||||
cronExpression: null,
|
||||
sidebarIndicators: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -46,6 +48,7 @@ describe('UpdatesSection', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Last checked 5m ago/)).toBeInTheDocument());
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/image-updates/status');
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeEnabled();
|
||||
expect(screen.getByLabelText(/Enable image update checks/i)).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows the section read-only (control disabled) for non-admins', async () => {
|
||||
@@ -61,4 +64,34 @@ describe('UpdatesSection', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('greys out cadence and shows Next check disabled when checks are off', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ...STATUS, enabled: false, nextCheckAt: null }),
|
||||
});
|
||||
render(<UpdatesSection />);
|
||||
await waitFor(() => expect(screen.getByText(/Next check disabled/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
|
||||
expect(screen.queryByText(/Show update status in sidebar/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the enable toggle with upgrade copy when enabled is absent', async () => {
|
||||
const older = {
|
||||
checking: STATUS.checking,
|
||||
intervalMinutes: STATUS.intervalMinutes,
|
||||
lastCheckedAt: STATUS.lastCheckedAt,
|
||||
nextCheckAt: STATUS.nextCheckAt,
|
||||
manualCooldownMinutes: STATUS.manualCooldownMinutes,
|
||||
manualCooldownRemainingMs: STATUS.manualCooldownRemainingMs,
|
||||
mode: STATUS.mode,
|
||||
cronExpression: STATUS.cronExpression,
|
||||
sidebarIndicators: STATUS.sidebarIndicators,
|
||||
};
|
||||
mockedFetch.mockResolvedValue({ ok: true, json: async () => older });
|
||||
render(<UpdatesSection />);
|
||||
await waitFor(() => expect(screen.getByText(/older version of Sencho/i)).toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/Enable image update checks/i)).toBeDisabled();
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user