mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only
The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.
The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.
Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
accessors, the three routes under /api/stacks/*/auto-update, the per-stack
skip in /api/auto-update/execute and SchedulerService.executeUpdate's
fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
optimistic toggle plumbing. Drop autoUpdateSettings state, the
/stacks/auto-update-settings fetch, and the auto-update-settings-changed
WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
now means "a covering enabled action='update' schedule exists" (per-stack
row or fleet row on this node, earliest next_run_at wins, per-stack row
wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
for the 60s poll. The broadcast is wrapped so a broken subscriber socket
cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
to describe the schedule-based model; update the matching troubleshooting
entry. The misleading fleet-update help text in ScheduledOperationsView
is corrected to reflect that every stack on the node is covered.
Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.
No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.
* docs: sweep remaining references to the per-stack auto-update toggle
The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:
- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
group description, the matching screenshot alt-text, and the Skipper
Note that listed it. Schedule task now carries the cross-link to
Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
"Auto-update stacks" to "Auto-update schedules" with the new value
shape, and rewrite the troubleshooting accordion to describe the
scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
Stacks row and helper text to reflect that every stack on the node is
covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
All Stacks schedules instead of the removed per-stack policy.
The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.
No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.
* fix(stacks): drop orphaned requireAdmin import after auto-update route removal
CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.
tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
This commit is contained in:
@@ -46,6 +46,9 @@ interface StackCard {
|
||||
previewLoaded: boolean;
|
||||
scheduledTask: ScheduledTask | null;
|
||||
applying: boolean;
|
||||
// True when at least one enabled action='update' scheduled task covers this
|
||||
// stack on this node (per-stack row or fleet row). Drives the Auto: Off pill
|
||||
// and the Apply now button's disabled state.
|
||||
autoUpdateEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -239,7 +242,7 @@ function StackReadinessCard({
|
||||
disabled={blocked || applying || !autoUpdateEnabled}
|
||||
title={
|
||||
!autoUpdateEnabled
|
||||
? 'Auto-updates are disabled for this stack. Update it from its actions menu.'
|
||||
? 'No schedule covers this stack. Create one in Schedules → Auto-update Stack.'
|
||||
: (blocked ? (blockedReason ?? undefined) : undefined)
|
||||
}
|
||||
className="gap-1.5"
|
||||
@@ -295,7 +298,7 @@ function ReadinessHero({
|
||||
{total > 0 && (
|
||||
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
||||
{ready} of {total} ready to apply automatically{acrossNodes}
|
||||
{total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''}
|
||||
{total - ready > 0 ? ` · ${total - ready} need a schedule or review` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -395,8 +398,6 @@ function AutoUpdateReadinessContent() {
|
||||
apiFetch('/image-updates/fleet', { localOnly: true }),
|
||||
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
|
||||
]);
|
||||
// Auto-update settings are per-node; fetch lazily after we know which nodes have updates.
|
||||
// Collected into a map keyed by nodeId once we know the fleet topology.
|
||||
if (token !== loadTokenRef.current) return;
|
||||
|
||||
if (!statusRes.ok) {
|
||||
@@ -406,33 +407,33 @@ function AutoUpdateReadinessContent() {
|
||||
setReachableNodeCount(Object.keys(fleetStatus).length);
|
||||
|
||||
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
|
||||
// A stack is "covered" by an enabled action='update' row when either
|
||||
// a per-stack row targets it or a fleet row targets its node. We pick
|
||||
// the earliest next-run covering task so the readiness card renders
|
||||
// the next-run time accurately for both shapes.
|
||||
const taskByNodeStack = new Map<string, ScheduledTask>();
|
||||
const fleetTaskByNode = new Map<number, ScheduledTask>();
|
||||
for (const t of tasks) {
|
||||
if (t.target_type !== 'stack' || !t.target_id) continue;
|
||||
// Tasks with node_id=null are local-node-scoped.
|
||||
if (!t.enabled) continue;
|
||||
// The fetch URL filters on action=update; this guard makes the
|
||||
// coverage check robust against a future regression there.
|
||||
if (t.action !== 'update') continue;
|
||||
const taskNodeId = t.node_id ?? localNodeId;
|
||||
if (taskNodeId == null) continue;
|
||||
const key = `${taskNodeId}::${t.target_id}`;
|
||||
const existing = taskByNodeStack.get(key);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
taskByNodeStack.set(key, t);
|
||||
if (t.target_type === 'fleet') {
|
||||
const existing = fleetTaskByNode.get(taskNodeId);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
fleetTaskByNode.set(taskNodeId, t);
|
||||
}
|
||||
} else if (t.target_type === 'stack' && t.target_id) {
|
||||
const key = `${taskNodeId}::${t.target_id}`;
|
||||
const existing = taskByNodeStack.get(key);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
taskByNodeStack.set(key, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch auto-update settings for all nodes that have pending updates.
|
||||
const nodeIdsWithUpdates = [...new Set(
|
||||
Object.keys(fleetStatus).map(Number).filter(id => Object.values(fleetStatus[String(id)]).some(Boolean))
|
||||
)];
|
||||
const autoUpdateByNode = new Map<number, Record<string, boolean>>();
|
||||
await Promise.all(nodeIdsWithUpdates.map(async (nodeId) => {
|
||||
try {
|
||||
const res = await fetchForNode('/stacks/auto-update-settings', nodeId);
|
||||
if (res.ok) autoUpdateByNode.set(nodeId, await res.json() as Record<string, boolean>);
|
||||
} catch {
|
||||
// If the fetch fails, default all stacks on that node to enabled.
|
||||
}
|
||||
}));
|
||||
|
||||
const flatPairs: { nodeId: number; stack: string }[] = [];
|
||||
const initialGroups: NodeGroup[] = [];
|
||||
const currentNodes = nodesRef.current;
|
||||
@@ -445,17 +446,24 @@ function AutoUpdateReadinessContent() {
|
||||
.map(([stack]) => stack)
|
||||
.sort();
|
||||
if (stacks.length === 0) continue;
|
||||
const nodeAutoUpdateSettings = autoUpdateByNode.get(nodeId) ?? {};
|
||||
const cards: StackCard[] = stacks.map(stack => {
|
||||
flatPairs.push({ nodeId, stack });
|
||||
const stackTask = taskByNodeStack.get(`${nodeId}::${stack}`) ?? null;
|
||||
const fleetTask = fleetTaskByNode.get(nodeId) ?? null;
|
||||
// Prefer whichever covering task fires next.
|
||||
// Earliest next-run wins; on a tie, the per-stack row beats the
|
||||
// fleet row so the user sees the more specific schedule.
|
||||
const scheduledTask = stackTask && fleetTask
|
||||
? ((stackTask.next_run_at ?? Infinity) <= (fleetTask.next_run_at ?? Infinity) ? stackTask : fleetTask)
|
||||
: (stackTask ?? fleetTask);
|
||||
return {
|
||||
stack,
|
||||
nodeId,
|
||||
preview: null,
|
||||
previewLoaded: false,
|
||||
scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null,
|
||||
scheduledTask,
|
||||
applying: false,
|
||||
autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true,
|
||||
autoUpdateEnabled: scheduledTask !== null,
|
||||
};
|
||||
});
|
||||
initialGroups.push({
|
||||
@@ -593,7 +601,15 @@ function AutoUpdateReadinessContent() {
|
||||
const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]);
|
||||
const { total, ready } = useMemo(() => {
|
||||
const t = flatCards.length;
|
||||
const r = flatCards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length;
|
||||
// "Ready" means a schedule covers the stack, the preview loaded without
|
||||
// error, and no major-bump blocked it. Without a covering schedule the
|
||||
// stack cannot apply automatically regardless of preview state.
|
||||
const r = flatCards.filter(c =>
|
||||
c.autoUpdateEnabled
|
||||
&& c.previewLoaded
|
||||
&& c.preview !== null
|
||||
&& !c.preview.summary.blocked,
|
||||
).length;
|
||||
return { total: t, ready: r };
|
||||
}, [flatCards]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
@@ -32,7 +32,7 @@ import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { StackSidebar } from '@/components/sidebar/StackSidebar';
|
||||
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
|
||||
import { useSidebarActivitySummary, countEnabledAutoUpdates } from '@/components/sidebar/useSidebarActivitySummary';
|
||||
import { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary';
|
||||
import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun';
|
||||
import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt';
|
||||
import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker';
|
||||
@@ -68,7 +68,6 @@ export default function EditorLayout() {
|
||||
|
||||
const stackListState = useStackListState();
|
||||
const {
|
||||
files,
|
||||
selectedFile,
|
||||
isLoading,
|
||||
stackActions: stackActionMap,
|
||||
@@ -76,7 +75,6 @@ export default function EditorLayout() {
|
||||
searchQuery, setSearchQuery,
|
||||
stackStatuses,
|
||||
stackLabelMap,
|
||||
autoUpdateSettings,
|
||||
filterChip, setFilterChip,
|
||||
bulkMode,
|
||||
selectedFiles,
|
||||
@@ -85,7 +83,6 @@ export default function EditorLayout() {
|
||||
remoteResults,
|
||||
isStackBusy,
|
||||
refreshStacks,
|
||||
fetchAutoUpdateSettings,
|
||||
handleScanStacks,
|
||||
scheduleStateInvalidateRefresh,
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
@@ -147,7 +144,6 @@ export default function EditorLayout() {
|
||||
} = useNotifications({
|
||||
nodes,
|
||||
onStateInvalidate: scheduleStateInvalidateRefresh,
|
||||
onAutoUpdateChange: fetchAutoUpdateSettings,
|
||||
onImageUpdatesChange: fetchImageUpdates,
|
||||
});
|
||||
|
||||
@@ -190,19 +186,12 @@ export default function EditorLayout() {
|
||||
|
||||
const panelStartedAt = usePanelSessionStartedAt(panelState);
|
||||
|
||||
const autoUpdateEnabledCount = useMemo(
|
||||
() => countEnabledAutoUpdates(files, autoUpdateSettings),
|
||||
[files, autoUpdateSettings],
|
||||
);
|
||||
|
||||
const nextAutoUpdateRunAt = useNextAutoUpdateRun();
|
||||
const activitySummary = useSidebarActivitySummary({
|
||||
notifications,
|
||||
tickerConnected,
|
||||
panelState,
|
||||
panelStartedAt,
|
||||
autoUpdateEnabledCount,
|
||||
totalStackCount: files.length,
|
||||
nextAutoUpdateRunAt,
|
||||
});
|
||||
|
||||
@@ -292,7 +281,6 @@ export default function EditorLayout() {
|
||||
}
|
||||
|
||||
refreshStacks();
|
||||
fetchAutoUpdateSettings();
|
||||
void stackActions.refreshGitSourcePending();
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('useNotifications', () => {
|
||||
|
||||
it('starts with empty notifications and disconnected state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
expect(result.current.notifications).toEqual([]);
|
||||
expect(result.current.tickerConnected).toBe(false);
|
||||
@@ -68,7 +68,7 @@ describe('useNotifications', () => {
|
||||
|
||||
it('opens a local notification WebSocket on mount', () => {
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
expect(MockWS.instances.length).toBeGreaterThanOrEqual(1);
|
||||
expect(MockWS.instances[0]).toBeDefined();
|
||||
@@ -76,7 +76,7 @@ describe('useNotifications', () => {
|
||||
|
||||
it('sets tickerConnected true when local WS opens', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
expect(result.current.tickerConnected).toBe(true);
|
||||
@@ -84,7 +84,7 @@ describe('useNotifications', () => {
|
||||
|
||||
it('adds notification when local WS receives notification message', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
@@ -98,7 +98,7 @@ describe('useNotifications', () => {
|
||||
|
||||
it('clearAllNotifications empties the local state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
@@ -114,9 +114,8 @@ describe('useNotifications', () => {
|
||||
it('fires onImageUpdatesChange on state-invalidate with action="stack-updated"', () => {
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
const onAutoUpdateChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
@@ -129,14 +128,13 @@ describe('useNotifications', () => {
|
||||
});
|
||||
expect(onImageUpdatesChange).toHaveBeenCalledTimes(1);
|
||||
expect(onStateInvalidate).toHaveBeenCalledTimes(1);
|
||||
expect(onAutoUpdateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire onImageUpdatesChange on a generic state-invalidate', () => {
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange: vi.fn(), onImageUpdatesChange }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
@@ -151,29 +149,9 @@ describe('useNotifications', () => {
|
||||
expect(onImageUpdatesChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire onImageUpdatesChange on auto-update-settings-changed', () => {
|
||||
const onAutoUpdateChange = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
const onStateInvalidate = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate', nodeId: 1, action: 'auto-update-settings-changed', ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(onAutoUpdateChange).toHaveBeenCalledTimes(1);
|
||||
expect(onImageUpdatesChange).not.toHaveBeenCalled();
|
||||
expect(onStateInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deleteNotification removes the matching item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
const notif = makeNotif({ id: 5, nodeId: localNode.id });
|
||||
|
||||
@@ -7,11 +7,10 @@ import type { NotificationItem } from '../../dashboard/types';
|
||||
interface UseNotificationsOptions {
|
||||
nodes: Node[];
|
||||
onStateInvalidate: () => void;
|
||||
onAutoUpdateChange: () => void;
|
||||
onImageUpdatesChange: () => void;
|
||||
}
|
||||
|
||||
export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }: UseNotificationsOptions) {
|
||||
export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }: UseNotificationsOptions) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
|
||||
@@ -22,8 +21,6 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange,
|
||||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||||
const onStateInvalidateRef = useRef(onStateInvalidate);
|
||||
onStateInvalidateRef.current = onStateInvalidate;
|
||||
const onAutoUpdateChangeRef = useRef(onAutoUpdateChange);
|
||||
onAutoUpdateChangeRef.current = onAutoUpdateChange;
|
||||
const onImageUpdatesChangeRef = useRef(onImageUpdatesChange);
|
||||
onImageUpdatesChangeRef.current = onImageUpdatesChange;
|
||||
|
||||
@@ -98,13 +95,9 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange,
|
||||
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
|
||||
if (msg.action === 'auto-update-settings-changed') {
|
||||
onAutoUpdateChangeRef.current();
|
||||
} else {
|
||||
onStateInvalidateRef.current();
|
||||
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
onStateInvalidateRef.current();
|
||||
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -52,7 +52,6 @@ export function useSidebarContextMenu({
|
||||
labels: stackListState.labels,
|
||||
assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id),
|
||||
menuVisibility: stackActions.getStackMenuVisibility(file),
|
||||
autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true,
|
||||
openAlertSheet: () => overlayState.openAlertSheet(file),
|
||||
openAutoHeal: () => overlayState.openAutoHeal(file),
|
||||
checkUpdates: () => stackActions.checkUpdatesForStack(),
|
||||
@@ -64,22 +63,6 @@ export function useSidebarContextMenu({
|
||||
remove: () => overlayState.openDeleteDialog(sName),
|
||||
pin: () => stackListState.pin(file),
|
||||
unpin: () => stackListState.unpin(file),
|
||||
setAutoUpdateEnabled: async (enabled: boolean) => {
|
||||
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: enabled }));
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(sName)}/auto-update`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: !enabled }));
|
||||
toast.error((err as Error)?.message || 'Failed to update auto-update setting.');
|
||||
}
|
||||
},
|
||||
toggleLabel: async (labelId: number) => {
|
||||
const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id);
|
||||
const assigned = currentIds.includes(labelId);
|
||||
@@ -142,7 +125,7 @@ export function useSidebarContextMenu({
|
||||
}, [
|
||||
stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral,
|
||||
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
|
||||
stackListState.autoUpdateSettings, stackListState.pin, stackListState.unpin,
|
||||
stackListState.pin, stackListState.unpin,
|
||||
]);
|
||||
|
||||
return buildMenuCtx;
|
||||
|
||||
@@ -44,7 +44,6 @@ export function useStackListState() {
|
||||
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
||||
const [labels, setLabels] = useState<StackLabel[]>([]);
|
||||
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
||||
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
|
||||
const [filterChip, setFilterChip] = useState<FilterChip>('all');
|
||||
const [bulkMode, setBulkMode] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
@@ -181,20 +180,6 @@ export function useStackListState() {
|
||||
const refreshStacksRef = useRef(refreshStacks);
|
||||
useEffect(() => { refreshStacksRef.current = refreshStacks; });
|
||||
|
||||
const fetchAutoUpdateSettings = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks/auto-update-settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAutoUpdateSettings(data as Record<string, boolean>);
|
||||
} else {
|
||||
console.error('[AutoUpdateSettings] fetch returned', res.status);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('[AutoUpdateSettings] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanStacks = async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
@@ -339,7 +324,6 @@ export function useStackListState() {
|
||||
stackPorts, setStackPorts,
|
||||
labels,
|
||||
stackLabelMap,
|
||||
autoUpdateSettings, setAutoUpdateSettings,
|
||||
filterChip, setFilterChip,
|
||||
bulkMode, setBulkMode,
|
||||
selectedFiles, setSelectedFiles,
|
||||
@@ -351,7 +335,6 @@ export function useStackListState() {
|
||||
setOptimisticStatus,
|
||||
refreshLabels,
|
||||
refreshStacks,
|
||||
fetchAutoUpdateSettings,
|
||||
handleScanStacks,
|
||||
scheduleStateInvalidateRefresh,
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
|
||||
@@ -736,7 +736,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Only stacks with auto-updates enabled on this node will be updated.</p>
|
||||
<p className="text-xs text-muted-foreground">Every stack on the selected node will be checked and updated when new images are available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -165,8 +165,8 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Auto-update stacks"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
|
||||
label="Auto-update schedules"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
{!automation.webhooks.locked && (
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('ConfigurationStatus tier parity', () => {
|
||||
expect(screen.queryByText('Notification routing')).toBeNull();
|
||||
expect(screen.queryByText('Automation')).toBeNull();
|
||||
expect(screen.queryByText('Auto-heal policies')).toBeNull();
|
||||
expect(screen.queryByText('Auto-update stacks')).toBeNull();
|
||||
expect(screen.queryByText('Auto-update schedules')).toBeNull();
|
||||
expect(screen.queryByText('Webhooks')).toBeNull();
|
||||
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
||||
expect(screen.queryByText('Vulnerability scanning')).toBeNull();
|
||||
@@ -121,7 +121,7 @@ describe('ConfigurationStatus tier parity', () => {
|
||||
|
||||
expect(screen.getByText('Automation')).toBeDefined();
|
||||
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
||||
expect(screen.getByText('Auto-update stacks')).toBeDefined();
|
||||
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
||||
expect(screen.getByText('Webhooks')).toBeDefined();
|
||||
expect(screen.getByText('Notification routing')).toBeDefined();
|
||||
expect(screen.getByText('Vulnerability scanning')).toBeDefined();
|
||||
|
||||
@@ -83,17 +83,17 @@ describe('useConfigurationStatus state-invalidate handling', () => {
|
||||
expect(apiFetchMock.mock.calls.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('refetches once on a settings-affecting auto-update-settings-changed event', async () => {
|
||||
it('refetches once on a scheduled-tasks invalidation', async () => {
|
||||
renderHook(() => useConfigurationStatus());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
const baseline = apiFetchMock.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
// Burst three settings-change events; the debounce should collapse
|
||||
// them into a single refetch.
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
// Burst three scheduled-tasks-change events; the debounce should
|
||||
// collapse them into a single refetch.
|
||||
fireInvalidate({ scope: 'scheduled-tasks', action: 'created' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks', action: 'toggled' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks', action: 'deleted' });
|
||||
});
|
||||
// Before debounce window elapses, no new fetch.
|
||||
expect(apiFetchMock.mock.calls.length).toBe(baseline);
|
||||
|
||||
@@ -80,19 +80,15 @@ export function useConfigurationStatus() {
|
||||
return visibilityInterval(guard, 60_000);
|
||||
}, [nodeId, fetchStatus]);
|
||||
|
||||
// Filter `sencho:state-invalidate` so only settings-affecting events
|
||||
// refetch the configuration; the high-frequency `scope: 'stack'` and
|
||||
// `scope: 'image-updates'` container/image bursts are ignored. Today the
|
||||
// only such settings event is `auto-update-settings-changed` (emitted by
|
||||
// the stack auto-update toggle). The filter is debounced and the
|
||||
// configuration response includes the toggled state, so a user editing
|
||||
// the setting sees the row update under a second instead of waiting up
|
||||
// to a minute.
|
||||
// Refetch the configuration card when a scheduled-tasks mutation fires
|
||||
// an invalidate. High-frequency `scope: 'stack'` and `scope: 'image-updates'`
|
||||
// events are ignored; they don't change any tile on this card. Debounced
|
||||
// so a burst of edits coalesces into a single fetch.
|
||||
useEffect(() => {
|
||||
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onInvalidate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail;
|
||||
if (detail?.action !== 'auto-update-settings-changed') return;
|
||||
const detail = (e as CustomEvent<{ scope?: string }>).detail;
|
||||
if (detail?.scope !== 'scheduled-tasks') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
|
||||
@@ -88,8 +88,7 @@ function buildConfig(summary: SidebarActivitySummary): RenderConfig {
|
||||
iconClass: 'text-warning',
|
||||
primary: (
|
||||
<span className="font-mono text-[11px] truncate">
|
||||
<span className="text-foreground">Auto-update </span>
|
||||
<span className="text-brand">{summary.enabledCount}/{summary.totalCount}</span>
|
||||
<span className="text-foreground">Auto-update</span>
|
||||
<span className="text-muted-foreground"> · next run {nextLabel}</span>
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -54,11 +54,11 @@ describe('SidebarActivityTicker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders automation state with counts, next-run time, and routes click to open-auto-updates', () => {
|
||||
it('renders automation state with next-run time, and routes click to open-auto-updates', () => {
|
||||
const nextRun = Math.floor(Date.now() / 1000) + 600;
|
||||
const { onAction } = renderWith({ kind: 'automation', enabledCount: 3, totalCount: 8, nextRunAt: nextRun });
|
||||
const { onAction } = renderWith({ kind: 'automation', nextRunAt: nextRun });
|
||||
expect(screen.getByText(/Auto-update/)).toBeInTheDocument();
|
||||
expect(screen.getByText('3/8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/next run/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({ kind: 'open-auto-updates' });
|
||||
|
||||
@@ -56,14 +56,14 @@ describe('useNextAutoUpdateRun', () => {
|
||||
expect(result.current).toBe(1_700);
|
||||
});
|
||||
|
||||
it('debounces rapid invalidations into a single refetch', async () => {
|
||||
it('debounces rapid scheduled-tasks invalidations into a single refetch', async () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
});
|
||||
// Debounce window not yet elapsed: still only the mount call.
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -76,8 +76,8 @@ describe('useNextAutoUpdateRun', () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
apiFetchMock.mockClear();
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'something-else' });
|
||||
fireInvalidate({ scope: 'unrelated' });
|
||||
fireInvalidate({ scope: 'stack' });
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
@@ -98,7 +98,7 @@ describe('useNextAutoUpdateRun', () => {
|
||||
apiFetchMock.mockClear();
|
||||
unmount();
|
||||
await act(async () => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
vi.advanceTimersByTime(120_000);
|
||||
});
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { __testing, countEnabledAutoUpdates } from '../useSidebarActivitySummary';
|
||||
import { __testing } from '../useSidebarActivitySummary';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
@@ -29,8 +29,6 @@ function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||
tickerConnected: true,
|
||||
panelState: IDLE_PANEL,
|
||||
panelStartedAt: null,
|
||||
autoUpdateEnabledCount: 0,
|
||||
totalStackCount: 0,
|
||||
nextAutoUpdateRunAt: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -69,8 +67,6 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
const recent = notif({ id: 10, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [failure, recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('failure');
|
||||
@@ -84,16 +80,12 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
expect(r.kind).not.toBe('failure');
|
||||
});
|
||||
|
||||
it('returns automation when auto-update is enabled and no recent event exists', () => {
|
||||
it('returns automation when a next-run is known and no recent event exists', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 2,
|
||||
totalStackCount: 4,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('automation');
|
||||
if (r.kind === 'automation') {
|
||||
expect(r.enabledCount).toBe(2);
|
||||
expect(r.totalCount).toBe(4);
|
||||
expect(r.nextRunAt).toBe(NOW_SECS + 3600);
|
||||
}
|
||||
});
|
||||
@@ -102,18 +94,14 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
const recent = notif({ id: 7, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('recent-event');
|
||||
if (r.kind === 'recent-event') expect(r.notif.id).toBe(7);
|
||||
});
|
||||
|
||||
it('drops automation when no next-run is known, even with auto-update settings present', () => {
|
||||
it('drops automation when no next-run is known', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: null,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
@@ -160,25 +148,3 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countEnabledAutoUpdates', () => {
|
||||
it('counts a stack with no explicit row as enabled (backend default-true contract)', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api'], {})).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit false', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api', 'db'], { api: false })).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit true', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { web: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty file list', () => {
|
||||
expect(countEnabledAutoUpdates([], { web: true })).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores settings rows that do not correspond to known files', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { ghost: false, web: true })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface StackMenuCtx {
|
||||
labels: Label[];
|
||||
assignedLabelIds: number[];
|
||||
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
|
||||
autoUpdateEnabled: boolean;
|
||||
openAlertSheet: () => void;
|
||||
openAutoHeal: () => void;
|
||||
checkUpdates: () => void;
|
||||
@@ -49,7 +48,6 @@ export interface StackMenuCtx {
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
openLabelManager: () => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
openScheduleTask: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ export function useNextAutoUpdateRun(): number | null {
|
||||
run();
|
||||
|
||||
const onInvalidate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail;
|
||||
if (detail?.action !== 'auto-update-settings-changed' && detail?.scope !== 'scheduled-tasks') return;
|
||||
const detail = (e as CustomEvent<{ scope?: string }>).detail;
|
||||
if (detail?.scope !== 'scheduled-tasks') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => { invalidateTimer = null; run(); }, INVALIDATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const RECENT_WINDOW_SECS = 60 * 60;
|
||||
export type SidebarActivitySummary =
|
||||
| { kind: 'active-op'; stackName: string; action: ActionVerb; startedAt: number }
|
||||
| { kind: 'failure'; notif: NotificationItem }
|
||||
| { kind: 'automation'; enabledCount: number; totalCount: number; nextRunAt: number }
|
||||
| { kind: 'automation'; nextRunAt: number }
|
||||
| { kind: 'recent-event'; notif: NotificationItem }
|
||||
| { kind: 'quiet-live' }
|
||||
| { kind: 'disconnected' };
|
||||
@@ -19,9 +19,6 @@ interface SummaryInputs {
|
||||
tickerConnected: boolean;
|
||||
panelState: DeployPanelState;
|
||||
panelStartedAt: number | null;
|
||||
/** Pre-aggregated by the caller so the memo dep list stays scalar; see EditorLayout. */
|
||||
autoUpdateEnabledCount: number;
|
||||
totalStackCount: number;
|
||||
nextAutoUpdateRunAt: number | null;
|
||||
}
|
||||
|
||||
@@ -54,7 +51,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific
|
||||
* 1. active-op: a deploy panel is preparing/streaming
|
||||
* 2. failure: newest unread stack-scoped error in the last 24h
|
||||
* 3. recent-event: newest non-error stack notification in the last hour
|
||||
* 4. automation: auto-update is enabled and a next run is scheduled
|
||||
* 4. automation: a next auto-update run is scheduled
|
||||
* 5. disconnected: notification WebSocket is down
|
||||
* 6. quiet-live: nothing else to surface
|
||||
*
|
||||
@@ -64,7 +61,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific
|
||||
* follow the same order; if you change the cascade, update both.
|
||||
*/
|
||||
function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivitySummary {
|
||||
const { panelState, panelStartedAt, notifications, autoUpdateEnabledCount, totalStackCount, nextAutoUpdateRunAt, tickerConnected } = inputs;
|
||||
const { panelState, panelStartedAt, notifications, nextAutoUpdateRunAt, tickerConnected } = inputs;
|
||||
|
||||
if (panelState.isOpen && (panelState.status === 'preparing' || panelState.status === 'streaming') && panelStartedAt !== null) {
|
||||
return { kind: 'active-op', stackName: panelState.stackName, action: panelState.action, startedAt: panelStartedAt };
|
||||
@@ -77,8 +74,8 @@ function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivityS
|
||||
}
|
||||
|
||||
const recent = findRecent(notifications, nowSecs);
|
||||
if (!recent && autoUpdateEnabledCount > 0 && nextAutoUpdateRunAt !== null) {
|
||||
return { kind: 'automation', enabledCount: autoUpdateEnabledCount, totalCount: totalStackCount, nextRunAt: nextAutoUpdateRunAt };
|
||||
if (!recent && nextAutoUpdateRunAt !== null) {
|
||||
return { kind: 'automation', nextRunAt: nextAutoUpdateRunAt };
|
||||
}
|
||||
|
||||
if (recent) {
|
||||
@@ -111,23 +108,10 @@ export function useSidebarActivitySummary(inputs: SummaryInputs): SidebarActivit
|
||||
inputs.panelState.action,
|
||||
inputs.panelState.status,
|
||||
inputs.panelStartedAt,
|
||||
inputs.autoUpdateEnabledCount,
|
||||
inputs.totalStackCount,
|
||||
inputs.nextAutoUpdateRunAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count stacks with auto-update enabled. Backend defaults missing rows to
|
||||
* enabled (DatabaseService.getStackAutoUpdateSettingsForNode); callers must
|
||||
* NOT treat absence as disabled.
|
||||
*/
|
||||
export function countEnabledAutoUpdates(files: string[], settings: Record<string, boolean>): number {
|
||||
let n = 0;
|
||||
for (const f of files) if (settings[f] ?? true) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Exported for unit tests so we don't need to spin up a renderer to validate cascade logic.
|
||||
export const __testing = { deriveSummary };
|
||||
|
||||
@@ -18,7 +18,6 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
labels: [],
|
||||
assignedLabelIds: [],
|
||||
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
|
||||
autoUpdateEnabled: true,
|
||||
openAlertSheet: vi.fn(),
|
||||
openAutoHeal: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
@@ -33,7 +32,6 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
toggleLabel: vi.fn(),
|
||||
createAndAssignLabel: vi.fn(),
|
||||
openLabelManager: vi.fn(),
|
||||
setAutoUpdateEnabled: vi.fn(),
|
||||
openScheduleTask: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -85,16 +83,15 @@ describe('useStackMenuItems', () => {
|
||||
expect(del.icon).toBe(Trash2);
|
||||
});
|
||||
|
||||
it('shows auto-update toggle in inspect when isPaid', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'auto-update')).toBeDefined();
|
||||
});
|
||||
|
||||
it('hides auto-update toggle when !isPaid', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'auto-update')).toBeUndefined();
|
||||
it('does not show an auto-update entry; Schedule task is the auto-update path', () => {
|
||||
const paid = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true })));
|
||||
const community = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
|
||||
for (const r of [paid, community]) {
|
||||
const groups = r.result.current;
|
||||
expect(groups.some(g => g.items.some(i => i.id === 'auto-update'))).toBe(false);
|
||||
}
|
||||
const lifecycle = paid.result.current.find(g => g.id === 'lifecycle')!;
|
||||
expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps label assignment available when !isPaid', () => {
|
||||
@@ -118,16 +115,6 @@ describe('useStackMenuItems', () => {
|
||||
expect(organize.items.find(i => i.id === 'pin')).toBeDefined();
|
||||
});
|
||||
|
||||
it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => {
|
||||
const setAutoUpdateEnabled = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true, setAutoUpdateEnabled }))
|
||||
);
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
inspect.items.find(i => i.id === 'auto-update')!.onSelect();
|
||||
expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('lifecycle items follow menuVisibility flags', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArrowUpRight,
|
||||
BellRing,
|
||||
CalendarClock,
|
||||
CircleSlash,
|
||||
Download,
|
||||
Pin,
|
||||
PinOff,
|
||||
@@ -22,7 +21,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
||||
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask,
|
||||
menuVisibility, openScheduleTask,
|
||||
} = ctx;
|
||||
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
|
||||
|
||||
@@ -34,12 +33,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
];
|
||||
if (isPaid) {
|
||||
inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal });
|
||||
inspect.push({
|
||||
id: 'auto-update',
|
||||
label: autoUpdateEnabled ? 'Auto-update: Enabled' : 'Auto-update: Disabled',
|
||||
icon: autoUpdateEnabled ? RefreshCw : CircleSlash,
|
||||
onSelect: () => setAutoUpdateEnabled(!autoUpdateEnabled),
|
||||
});
|
||||
}
|
||||
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
|
||||
if (stackStatus === 'running' && hasPort) {
|
||||
@@ -89,7 +82,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
}, [
|
||||
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
|
||||
showDeploy, showStop, showRestart, showUpdate,
|
||||
autoUpdateEnabled, setAutoUpdateEnabled,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user