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
This commit is contained in:
Anso
2026-04-25 11:49:01 -04:00
committed by GitHub
parent af9cb0aa63
commit 819d2a63fc
5 changed files with 58 additions and 27 deletions
+14 -2
View File
@@ -47,7 +47,7 @@ import { Sparkline } from './ui/sparkline';
import { GlobalObservabilityView } from './GlobalObservabilityView'; import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView'; import { FleetView } from './FleetView';
import { AuditLogView } from './AuditLogView'; import { AuditLogView } from './AuditLogView';
import ScheduledOperationsView from './ScheduledOperationsView'; import ScheduledOperationsView, { type ScheduleTaskPrefill } from './ScheduledOperationsView';
import AutoUpdateReadinessView from './AutoUpdateReadinessView'; import AutoUpdateReadinessView from './AutoUpdateReadinessView';
import { SecurityHistoryView } from './SecurityHistoryView'; import { SecurityHistoryView } from './SecurityHistoryView';
import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; 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 [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 [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null); const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingCompose, setEditingCompose] = useState(false); const [editingCompose, setEditingCompose] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -2004,6 +2006,11 @@ export default function EditorLayout() {
} }
}, },
openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }, 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
@@ -2811,7 +2818,12 @@ export default function EditorLayout() {
</CapabilityGate> </CapabilityGate>
) : activeView === 'scheduled-ops' ? ( ) : activeView === 'scheduled-ops' ? (
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations"> <CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} /> <ScheduledOperationsView
filterNodeId={filterNodeId}
onClearFilter={() => setFilterNodeId(null)}
prefill={schedulePrefill}
onPrefillConsumed={handlePrefillConsumed}
/>
</CapabilityGate> </CapabilityGate>
) : ( ) : (
<HomeDashboard <HomeDashboard
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -51,12 +51,19 @@ function formatRelative(ts: number, now: number): string {
return remMins === 0 ? `in ${hours}h` : `in ${hours}h ${remMins}m`; return remMins === 0 ? `in ${hours}h` : `in ${hours}h ${remMins}m`;
} }
export interface ScheduleTaskPrefill {
stackName: string;
nodeId: number | null;
}
interface ScheduledOperationsViewProps { interface ScheduledOperationsViewProps {
filterNodeId?: number | null; filterNodeId?: number | null;
onClearFilter?: () => void; onClearFilter?: () => 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<ScheduledTask[]>([]); const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [view, setView] = useState<'timeline' | 'table'>('timeline'); const [view, setView] = useState<'timeline' | 'table'>('timeline');
@@ -96,6 +103,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
? nodes.find(n => n.id === filterNodeId)?.name ? nodes.find(n => n.id === filterNodeId)?.name
: null; : null;
const consumedPrefillRef = useRef<ScheduleTaskPrefill | null>(null);
const fetchTasks = useCallback(async () => { const fetchTasks = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -141,6 +150,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
fetchNodes(); fetchNodes();
}, [fetchTasks, fetchStacks, 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(() => { useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000); const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -177,21 +193,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
} }
}, [formNodeId, dialogOpen, fetchStacks]); }, [formNodeId, dialogOpen, fetchStacks]);
const openCreate = () => { const openCreate = (prefillData?: { stackName: string; nodeId: string }) => {
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
setEditingTask(null); setEditingTask(null);
setFormName(''); setFormName('');
setFormAction('restart'); setFormAction('restart');
setFormTargetId(''); setFormTargetId(prefillData?.stackName ?? '');
setFormNodeId(filterNodeId != null ? String(filterNodeId) : ''); setFormNodeId(nodeId);
setFormCron('0 3 * * *'); setFormCron('0 3 * * *');
setFormEnabled(true); setFormEnabled(true);
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
setFormTargetServices([]); setFormTargetServices([]);
setFormPruneLabelFilter(''); setFormPruneLabelFilter('');
setDialogOpen(true); setDialogOpen(true);
if (filterNodeId != null) { if (nodeId) fetchStacks(nodeId);
fetchStacks(String(filterNodeId));
}
}; };
const openEdit = (task: ScheduledTask) => { const openEdit = (task: ScheduledTask) => {
@@ -380,7 +395,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh Refresh
</Button> </Button>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={() => openCreate()}>
<Plus className="w-4 h-4 mr-2" strokeWidth={1.5} /> <Plus className="w-4 h-4 mr-2" strokeWidth={1.5} />
New Schedule New Schedule
</Button> </Button>
@@ -26,9 +26,6 @@ export interface StackMenuCtx {
hasPort: boolean; hasPort: boolean;
isBusy: boolean; isBusy: boolean;
isPaid: 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; isAdmiral: boolean;
canDelete: boolean; canDelete: boolean;
isPinned: boolean; isPinned: boolean;
@@ -51,6 +48,7 @@ export interface StackMenuCtx {
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>; createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
openLabelManager: () => void; openLabelManager: () => void;
setAutoUpdateEnabled: (enabled: boolean) => void; setAutoUpdateEnabled: (enabled: boolean) => void;
openScheduleTask: () => void;
} }
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled'; export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';
@@ -32,6 +32,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
createAndAssignLabel: vi.fn(), createAndAssignLabel: vi.fn(),
openLabelManager: vi.fn(), openLabelManager: vi.fn(),
setAutoUpdateEnabled: vi.fn(), setAutoUpdateEnabled: vi.fn(),
openScheduleTask: vi.fn(),
...overrides, ...overrides,
}; };
} }
@@ -82,15 +83,6 @@ describe('useStackMenuItems', () => {
expect(del.icon).toBe(Trash2); 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', () => { it('shows auto-update toggle in inspect when isPaid', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true }))); const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true })));
const inspect = result.current.find(g => g.id === 'inspect')!; const inspect = result.current.find(g => g.id === 'inspect')!;
@@ -113,12 +105,24 @@ describe('useStackMenuItems', () => {
expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false); 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({ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
isBusy: true, isBusy: true,
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true }, menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true },
}))); })));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!; 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();
}); });
}); });
+4 -2
View File
@@ -3,6 +3,7 @@ import {
Activity, Activity,
ArrowUpRight, ArrowUpRight,
BellRing, BellRing,
CalendarClock,
CircleSlash, CircleSlash,
Download, Download,
Pin, Pin,
@@ -21,7 +22,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels, stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp, openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask,
} = ctx; } = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility; 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 (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 (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 (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 (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
if (canDelete) { if (canDelete) {
@@ -89,6 +91,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
showDeploy, showStop, showRestart, showUpdate, showDeploy, showStop, showRestart, showUpdate,
autoUpdateEnabled, setAutoUpdateEnabled, autoUpdateEnabled, setAutoUpdateEnabled,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp, openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
]); ]);
} }