From 819d2a63fcfd124609e3041f5dbae186660dd8dd Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 25 Apr 2026 11:49:01 -0400 Subject: [PATCH] feat(stacks): add Schedule task shortcut to stack context and kebab menus (#772) Right-clicking a stack or opening its 3-dot menu now shows a 'Schedule task' entry in the lifecycle group (visible to paid tiers). Clicking it navigates to Scheduled Operations and opens the New Schedule dialog pre-filled with the stack name and active node, removing the need to navigate there manually and re-enter the target. - Added openScheduleTask to StackMenuCtx; wired in buildMenuCtx using the active node from NodeContext - Extended ScheduledOperationsView with optional prefill/onPrefillConsumed props; a ref-guarded effect calls openCreate() with the prefill data - openCreate refactored to accept an optional prefill arg, removing the duplication between the effect and the existing 'New Schedule' button --- frontend/src/components/EditorLayout.tsx | 16 +++++++-- .../components/ScheduledOperationsView.tsx | 33 ++++++++++++++----- .../src/components/sidebar/sidebar-types.ts | 4 +-- .../__tests__/useStackMenuItems.test.tsx | 26 ++++++++------- frontend/src/hooks/useStackMenuItems.tsx | 6 ++-- 5 files changed, 58 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 92342b28..60fe540e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -47,7 +47,7 @@ import { Sparkline } from './ui/sparkline'; import { GlobalObservabilityView } from './GlobalObservabilityView'; import { FleetView } from './FleetView'; import { AuditLogView } from './AuditLogView'; -import ScheduledOperationsView from './ScheduledOperationsView'; +import ScheduledOperationsView, { type ScheduleTaskPrefill } from './ScheduledOperationsView'; import AutoUpdateReadinessView from './AutoUpdateReadinessView'; import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; @@ -322,6 +322,8 @@ export default function EditorLayout() { const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard'); const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); const [filterNodeId, setFilterNodeId] = useState(null); + const [schedulePrefill, setSchedulePrefill] = useState(null); + const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); const [isEditing, setIsEditing] = useState(false); const [editingCompose, setEditingCompose] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -2004,6 +2006,11 @@ export default function EditorLayout() { } }, openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }, + openScheduleTask: () => { + const stackName = file.replace(/\.(yml|yaml)$/, ''); + setSchedulePrefill({ stackName, nodeId: activeNode?.id ?? null }); + setActiveView('scheduled-ops'); + }, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -2811,7 +2818,12 @@ export default function EditorLayout() { ) : activeView === 'scheduled-ops' ? ( - setFilterNodeId(null)} /> + setFilterNodeId(null)} + prefill={schedulePrefill} + onPrefillConsumed={handlePrefillConsumed} + /> ) : ( void; + prefill?: ScheduleTaskPrefill | null; + onPrefillConsumed?: () => void; } -export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) { +export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [view, setView] = useState<'timeline' | 'table'>('timeline'); @@ -96,6 +103,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ? nodes.find(n => n.id === filterNodeId)?.name : null; + const consumedPrefillRef = useRef(null); + const fetchTasks = useCallback(async () => { setLoading(true); try { @@ -141,6 +150,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: fetchNodes(); }, [fetchTasks, fetchStacks, fetchNodes]); + useEffect(() => { + if (!prefill || prefill === consumedPrefillRef.current) return; + consumedPrefillRef.current = prefill; + openCreate({ stackName: prefill.stackName, nodeId: prefill.nodeId != null ? String(prefill.nodeId) : '' }); + onPrefillConsumed?.(); + }, [prefill, onPrefillConsumed]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { const id = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(id); @@ -177,21 +193,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: } }, [formNodeId, dialogOpen, fetchStacks]); - const openCreate = () => { + const openCreate = (prefillData?: { stackName: string; nodeId: string }) => { + const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); setEditingTask(null); setFormName(''); setFormAction('restart'); - setFormTargetId(''); - setFormNodeId(filterNodeId != null ? String(filterNodeId) : ''); + setFormTargetId(prefillData?.stackName ?? ''); + setFormNodeId(nodeId); setFormCron('0 3 * * *'); setFormEnabled(true); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setFormTargetServices([]); setFormPruneLabelFilter(''); setDialogOpen(true); - if (filterNodeId != null) { - fetchStacks(String(filterNodeId)); - } + if (nodeId) fetchStacks(nodeId); }; const openEdit = (task: ScheduledTask) => { @@ -380,7 +395,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: Refresh - diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index 4b9b74dd..8d3bfc95 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -26,9 +26,6 @@ export interface StackMenuCtx { hasPort: boolean; isBusy: boolean; isPaid: boolean; - // isAdmiral: plumbed now so Admiral-specific menu items can be added in a - // follow-up PR without changing this interface. No Admiral-only items ship - // in the current PR; the auto-update toggle is Skipper+, gated on isPaid. isAdmiral: boolean; canDelete: boolean; isPinned: boolean; @@ -51,6 +48,7 @@ export interface StackMenuCtx { createAndAssignLabel: (name: string, color: LabelColor) => Promise; openLabelManager: () => void; setAutoUpdateEnabled: (enabled: boolean) => void; + openScheduleTask: () => void; } export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled'; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 60ccca5d..0df55541 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -32,6 +32,7 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { createAndAssignLabel: vi.fn(), openLabelManager: vi.fn(), setAutoUpdateEnabled: vi.fn(), + openScheduleTask: vi.fn(), ...overrides, }; } @@ -82,15 +83,6 @@ describe('useStackMenuItems', () => { expect(del.icon).toBe(Trash2); }); - it('lifecycle items follow menuVisibility flags', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ - menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true }, - }))); - const lifecycle = result.current.find(g => g.id === 'lifecycle')!; - const ids = lifecycle.items.map(i => i.id); - expect(ids).toEqual(['deploy', 'update']); - }); - 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')!; @@ -113,12 +105,24 @@ describe('useStackMenuItems', () => { expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false); }); - it('disables every lifecycle item when isBusy', () => { + it('lifecycle items follow menuVisibility flags', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ + menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true }, + }))); + const lifecycle = result.current.find(g => g.id === 'lifecycle')!; + const ids = lifecycle.items.map(i => i.id); + expect(ids).toEqual(['deploy', 'update', 'schedule']); + }); + + it('disables action lifecycle items when isBusy but leaves schedule enabled', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isBusy: true, menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true }, }))); const lifecycle = result.current.find(g => g.id === 'lifecycle')!; - expect(lifecycle.items.every(i => i.disabled === true)).toBe(true); + const actionItems = lifecycle.items.filter(i => i.id !== 'schedule'); + expect(actionItems.every(i => i.disabled === true)).toBe(true); + const scheduleItem = lifecycle.items.find(i => i.id === 'schedule')!; + expect(scheduleItem.disabled).toBeFalsy(); }); }); diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 759053f9..21d704b5 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -3,6 +3,7 @@ import { Activity, ArrowUpRight, BellRing, + CalendarClock, CircleSlash, Download, Pin, @@ -21,7 +22,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, - menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, + menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask, } = ctx; const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility; @@ -74,6 +75,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy }); if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy }); if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); + if (isPaid) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -89,6 +91,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] showDeploy, showStop, showRestart, showUpdate, autoUpdateEnabled, setAutoUpdateEnabled, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, - deploy, stop, restart, update, remove, pin, unpin, toggleLabel, + deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask, ]); }