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
@@ -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 { Button } from '@/components/ui/button';
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`;
}
export interface ScheduleTaskPrefill {
stackName: string;
nodeId: number | null;
}
interface ScheduledOperationsViewProps {
filterNodeId?: number | null;
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 [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<ScheduleTaskPrefill | null>(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 }:
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh
</Button>
<Button size="sm" onClick={openCreate}>
<Button size="sm" onClick={() => openCreate()}>
<Plus className="w-4 h-4 mr-2" strokeWidth={1.5} />
New Schedule
</Button>