mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes (#545)
* fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes
- Fix orphaned task runs on policy/node deletion with transaction-wrapped cascade deletes
- Make manual trigger non-blocking (202 Accepted) to prevent proxy timeouts
- Distinguish registry check failures from clean "no update" results via structured ImageCheckResult
- Trim whitespace-only policy names in both frontend and backend validation
- Add strokeWidth={1.5} to action icons per design system
- Add sr-only DialogDescription for Radix accessibility
- Replace Select with Combobox for frequency picker
- Wrap run history sheet content in ScrollArea
- Support concurrent Run Now indicators via Set-based state
- Abort stale stack fetches on node switch with AbortController
- Add standard and diagnostic logging to SchedulerService and ImageUpdateService
- Add tests for cascade deletes, image checking, and scheduler edge cases
- Add troubleshooting section to auto-update docs
* fix(tests): resolve lint errors in image-update-service tests
Remove unused mock variables (mockGetImage, mockGetDocker) and unused
ImageCheckResult type import. Replace CommonJS require('yaml') with
ESM import to satisfy no-require-imports rule.
* chore(deps): bump Docker CLI to 29.4.0 and Compose to v5.1.2
Resolves Trivy CVE-2026-32282 (Go stdlib symlink follow in Root.Chmod)
by upgrading to releases that ship Go 1.25.9. Compose v5.1.2 also bumps
grpc to 1.80.0, resolving CVE-2026-33186.
* chore(security): accept CVE-2026-32282 in .trivyignore, update stale refs
Go stdlib symlink-following in Root.Chmod (CVE-2026-32282) affects both
Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). Fix
requires Go 1.25.9 or 1.26.2; no upstream static binary ships a patched
runtime yet. The vulnerable code path requires a chroot context with
attacker-controlled filesystem, which does not apply to our usage.
Also updates version references from v5.1.1/v29.3.1 to v5.1.2/v29.4.0
for existing CVE entries, and notes that Compose v5.1.2 resolved
CVE-2026-33186 (grpc bumped to 1.80.0) for the compose binary.
This commit is contained in:
@@ -2,13 +2,13 @@ import { useState, useEffect, useCallback } 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';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
@@ -96,7 +96,7 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
const [formCronPreset, setFormCronPreset] = useState('0 3 * * *');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runningPolicyId, setRunningPolicyId] = useState<number | null>(null);
|
||||
const [runningPolicies, setRunningPolicies] = useState<Set<number>>(new Set());
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
const [runsTotal, setRunsTotal] = useState(0);
|
||||
const runsLimit = 20;
|
||||
@@ -126,14 +126,16 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async (nodeId?: string) => {
|
||||
const fetchStacks = useCallback(async (nodeId?: string, signal?: AbortSignal) => {
|
||||
try {
|
||||
const res = nodeId
|
||||
? await fetchForNode('/stacks', parseInt(nodeId, 10))
|
||||
: await apiFetch('/stacks');
|
||||
? await fetchForNode('/stacks', parseInt(nodeId, 10), { signal })
|
||||
: await apiFetch('/stacks', { signal });
|
||||
if (res.ok) setStacks(await res.json());
|
||||
else setStacks([]);
|
||||
} catch { setStacks([]); }
|
||||
} catch {
|
||||
if (!signal?.aborted) setStacks([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
@@ -154,10 +156,11 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
|
||||
// Re-fetch stacks when selected node changes in the dialog
|
||||
useEffect(() => {
|
||||
if (dialogOpen && formNodeId) {
|
||||
fetchStacks(formNodeId);
|
||||
setFormTargetId('');
|
||||
}
|
||||
if (!dialogOpen || !formNodeId) return;
|
||||
const controller = new AbortController();
|
||||
fetchStacks(formNodeId, controller.signal);
|
||||
setFormTargetId('');
|
||||
return () => controller.abort();
|
||||
}, [formNodeId, dialogOpen, fetchStacks]);
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -188,7 +191,7 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
|
||||
const handleSave = async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
name: formName.trim(),
|
||||
target_type: 'stack',
|
||||
action: 'update',
|
||||
target_id: formTargetId,
|
||||
@@ -267,7 +270,7 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
};
|
||||
|
||||
const handleRunNow = async (policy: ScheduledTask) => {
|
||||
setRunningPolicyId(policy.id);
|
||||
setRunningPolicies(prev => new Set(prev).add(policy.id));
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${policy.id}/run`, { method: 'POST', localOnly: true });
|
||||
if (res.ok) {
|
||||
@@ -280,7 +283,11 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
} finally {
|
||||
setRunningPolicyId(null);
|
||||
setRunningPolicies(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(policy.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -375,17 +382,17 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRunNow(policy)} title="Run now" disabled={runningPolicyId === policy.id}>
|
||||
<Play className={`w-4 h-4 ${runningPolicyId === policy.id ? 'animate-pulse' : ''}`} />
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRunNow(policy)} title="Run now" disabled={runningPolicies.has(policy.id)}>
|
||||
<Play className={`w-4 h-4 ${runningPolicies.has(policy.id) ? 'animate-pulse' : ''}`} strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openRuns(policy)} title="Execution history">
|
||||
<History className="w-4 h-4" />
|
||||
<History className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(policy)} title="Edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(policy)} title="Delete" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -402,6 +409,7 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPolicy ? 'Edit Auto-Update Policy' : 'New Auto-Update Policy'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Configure an auto-update policy for a stack.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -439,19 +447,17 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Check Frequency</Label>
|
||||
<Select value={formCronPreset} onValueChange={(val) => {
|
||||
setFormCronPreset(val);
|
||||
if (val !== 'custom') setFormCron(val);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CRON_PRESETS.map(p => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
options={CRON_PRESETS.map(p => ({ value: p.value, label: p.label }))}
|
||||
value={formCronPreset}
|
||||
onValueChange={(val) => {
|
||||
setFormCronPreset(val);
|
||||
if (val !== 'custom') setFormCron(val);
|
||||
}}
|
||||
placeholder="Select frequency..."
|
||||
searchPlaceholder="Search frequencies..."
|
||||
emptyText="No matching frequency."
|
||||
/>
|
||||
{formCronPreset === 'custom' && (
|
||||
<Input
|
||||
placeholder="0 3 * * *"
|
||||
@@ -470,7 +476,7 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formName || !formCron || !formTargetId || !formNodeId}>
|
||||
<Button onClick={handleSave} disabled={saving || !formName.trim() || !formCron || !formTargetId || !formNodeId}>
|
||||
{saving ? 'Saving...' : editingPolicy ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -513,7 +519,8 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="mt-4">
|
||||
<ScrollArea className="mt-4 flex-1" style={{ maxHeight: 'calc(100vh - 10rem)' }}>
|
||||
<div>
|
||||
{runsLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||
) : runs.length === 0 ? (
|
||||
@@ -570,17 +577,18 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage - 1)} disabled={runsPage <= 1}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage + 1)} disabled={runsPage >= Math.ceil(runsTotal / runsLimit)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user