mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(schedules): next-24h timeline + merge auto-update into schedules (#681)
* feat(backend): add stack update-preview endpoint for readiness board Adds GET /api/stacks/:stackName/update-preview that returns per-image semver diff, bump classification, and a stack-level summary powering the Auto-Update readiness board. - New UpdatePreviewService parses compose images, inspects local digests, fetches remote digests and tag lists, and finds the highest compatible semver tag. - Major bumps are flagged blocked until human review; unknown bumps rank below real semver so they cannot mask a major. - Rollback target is reconstructed through parseImageRef to preserve registry ports and drop the Docker Hub library/ prefix. - Registry helpers (httpGet, auth token, digest, tag list, ref parse) are extracted into registry-api.ts and shared with ImageUpdateService. - 28 Vitest cases cover parse, selection, bump math, digest rebuilds, blocked policy, and rollback target construction. * feat(schedules): next-24h timeline, merge auto-update crud, add readiness board Replace the flat task table with a Timeline view as the default, showing the next 24 hours of scheduled work across four lanes (Restart, Update, Scan, Prune) with a live now rail and per-firing pills. The All tasks tab preserves the existing CRUD surface. Merge Auto-update Stack into Schedules as a first-class action and replace the standalone Auto-Update Policies view with a per-stack Readiness board that surfaces version diffs, risk tags, changelog previews, and rollback targets sourced from the stack update-preview endpoint.
This commit is contained in:
@@ -1,558 +0,0 @@
|
||||
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 { Combobox } from '@/components/ui/combobox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
|
||||
|
||||
const CRON_PRESETS = [
|
||||
{ label: 'Every 6 hours', value: '0 */6 * * *' },
|
||||
{ label: 'Every 12 hours', value: '0 */12 * * *' },
|
||||
{ label: 'Daily at 3 AM', value: '0 3 * * *' },
|
||||
{ label: 'Daily at midnight', value: '0 0 * * *' },
|
||||
{ label: 'Weekly (Sunday 3 AM)', value: '0 3 * * 0' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
];
|
||||
|
||||
interface AutoUpdatePoliciesProps {
|
||||
filterNodeId?: number | null;
|
||||
onClearFilter?: () => void;
|
||||
}
|
||||
|
||||
function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
|
||||
const [policies, setPolicies] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<ScheduledTask | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledTask | null>(null);
|
||||
const [runsTask, setRunsTask] = useState<ScheduledTask | null>(null);
|
||||
const [runs, setRuns] = useState<TaskRun[]>([]);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formTargetId, setFormTargetId] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState('');
|
||||
const [formCron, setFormCron] = useState('0 3 * * *');
|
||||
const [formCronPreset, setFormCronPreset] = useState('0 3 * * *');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runningPolicies, setRunningPolicies] = useState<Set<number>>(new Set());
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
const [runsTotal, setRunsTotal] = useState(0);
|
||||
const runsLimit = 20;
|
||||
|
||||
// Available stacks and nodes
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const filteredPolicies = filterNodeId != null
|
||||
? policies.filter(p => p.node_id === filterNodeId)
|
||||
: policies;
|
||||
const filterNodeName = filterNodeId != null
|
||||
? nodes.find(n => n.id === filterNodeId)?.name
|
||||
: null;
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/scheduled-tasks?action=update', { localOnly: true });
|
||||
if (res.ok) {
|
||||
setPolicies(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async (nodeId?: string, signal?: AbortSignal) => {
|
||||
try {
|
||||
const res = nodeId
|
||||
? await fetchForNode('/stacks', parseInt(nodeId, 10), { signal })
|
||||
: await apiFetch('/stacks', { signal });
|
||||
if (res.ok) setStacks(await res.json());
|
||||
else setStacks([]);
|
||||
} catch {
|
||||
if (!signal?.aborted) setStacks([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })));
|
||||
}
|
||||
} catch { /* Non-critical */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPolicies();
|
||||
fetchStacks();
|
||||
fetchNodes();
|
||||
}, [fetchPolicies, fetchStacks, fetchNodes]);
|
||||
|
||||
// Re-fetch stacks when selected node changes in the dialog
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || !formNodeId) return;
|
||||
const controller = new AbortController();
|
||||
fetchStacks(formNodeId, controller.signal);
|
||||
setFormTargetId('');
|
||||
return () => controller.abort();
|
||||
}, [formNodeId, dialogOpen, fetchStacks]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingPolicy(null);
|
||||
setFormName('');
|
||||
setFormTargetId('');
|
||||
setFormNodeId(filterNodeId != null ? String(filterNodeId) : '');
|
||||
setFormCron('0 3 * * *');
|
||||
setFormCronPreset('0 3 * * *');
|
||||
setFormEnabled(true);
|
||||
setDialogOpen(true);
|
||||
if (filterNodeId != null) {
|
||||
fetchStacks(String(filterNodeId));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (policy: ScheduledTask) => {
|
||||
setEditingPolicy(policy);
|
||||
setFormName(policy.name);
|
||||
setFormTargetId(policy.target_id || '');
|
||||
setFormNodeId(policy.node_id != null ? String(policy.node_id) : '');
|
||||
setFormCron(policy.cron_expression);
|
||||
const matchingPreset = CRON_PRESETS.find(p => p.value === policy.cron_expression);
|
||||
setFormCronPreset(matchingPreset ? matchingPreset.value : 'custom');
|
||||
setFormEnabled(policy.enabled === 1);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName.trim(),
|
||||
target_type: 'stack',
|
||||
action: 'update',
|
||||
target_id: formTargetId,
|
||||
node_id: formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
cron_expression: formCron,
|
||||
enabled: formEnabled,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingPolicy
|
||||
? await apiFetch(`/scheduled-tasks/${editingPolicy.id}`, { method: 'PUT', body: JSON.stringify(body), localOnly: true })
|
||||
: await apiFetch('/scheduled-tasks', { method: 'POST', body: JSON.stringify(body), localOnly: true });
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingPolicy ? 'Policy updated' : 'Policy created');
|
||||
setDialogOpen(false);
|
||||
fetchPolicies();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to save policy');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (policy: ScheduledTask) => {
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${policy.id}/toggle`, { method: 'PATCH', localOnly: true });
|
||||
if (res.ok) {
|
||||
fetchPolicies();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to toggle policy');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${deleteTarget.id}`, { method: 'DELETE', localOnly: true });
|
||||
if (res.ok) {
|
||||
toast.success('Policy deleted');
|
||||
setDeleteTarget(null);
|
||||
fetchPolicies();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to delete policy');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const openRuns = async (task: ScheduledTask, page = 1) => {
|
||||
setRunsTask(task);
|
||||
setRunsPage(page);
|
||||
setRunsLoading(true);
|
||||
const offset = (page - 1) * runsLimit;
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=${runsLimit}&offset=${offset}`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRuns(data.runs);
|
||||
setRunsTotal(data.total);
|
||||
}
|
||||
} catch { /* Non-critical */ }
|
||||
finally { setRunsLoading(false); }
|
||||
};
|
||||
|
||||
const handleRunNow = async (policy: ScheduledTask) => {
|
||||
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) {
|
||||
toast.success(`Checking for updates on "${policy.target_id}"...`);
|
||||
fetchPolicies();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to run policy');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
} finally {
|
||||
setRunningPolicies(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(policy.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" strokeWidth={1.5} />
|
||||
<CardTitle>Auto-Update Policies</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchPolicies} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Policy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Automatically check for new images and update your stacks on a schedule.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filterNodeId != null && filterNodeName && (
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<Badge variant="outline" className="gap-1.5 text-xs">
|
||||
Filtered to node: <span className="font-medium">{filterNodeName}</span>
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onClearFilter}>
|
||||
Clear filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading && filteredPolicies.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : filteredPolicies.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
{filterNodeId != null
|
||||
? 'No auto-update policies for this node. Create one to keep your stacks up to date automatically.'
|
||||
: 'No auto-update policies yet. Create one to keep your stacks up to date automatically.'}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Stack</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Run</TableHead>
|
||||
<TableHead>Next Run</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPolicies.map((policy) => (
|
||||
<TableRow key={policy.id}>
|
||||
<TableCell className="font-medium">{policy.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-muted-foreground">{policy.target_id === '*' ? 'All Stacks' : policy.target_id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{getCronDescription(policy.cron_expression)}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{policy.cron_expression}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{policy.last_status === 'success' ? (
|
||||
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
|
||||
) : policy.last_status === 'failure' ? (
|
||||
<Badge variant="destructive">Failed</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Never run</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(policy.last_run_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(policy.next_run_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={policy.enabled === 1}
|
||||
onCheckedChange={() => handleToggle(policy)}
|
||||
/>
|
||||
</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={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" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(policy)} title="Edit">
|
||||
<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" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<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">
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="e.g. Update media stack nightly" value={formName} onChange={e => setFormName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodes.map(n => ({ value: String(n.id), label: n.name }))}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
searchPlaceholder="Search nodes..."
|
||||
emptyText="No nodes found."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stack</Label>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: '*', label: 'All Stacks' },
|
||||
...stacks.map(s => ({ value: s, label: s })),
|
||||
]}
|
||||
value={formTargetId}
|
||||
onValueChange={setFormTargetId}
|
||||
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
|
||||
searchPlaceholder="Search stacks..."
|
||||
emptyText="No stacks found."
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Check Frequency</Label>
|
||||
<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 * * *"
|
||||
value={formCron}
|
||||
onChange={e => setFormCron(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{cronDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} id="policy-enabled" />
|
||||
<Label htmlFor="policy-enabled">Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formName.trim() || !formCron || !formTargetId || !formNodeId}>
|
||||
{saving ? 'Saving...' : editingPolicy ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Auto-Update Policy</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete “{deleteTarget?.name}”? This will also remove all execution history. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Run History Sheet */}
|
||||
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
|
||||
<SheetContent className="sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle>Update History - {runsTask?.name}</SheetTitle>
|
||||
{runsTask && runs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank')}
|
||||
title="Export as CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<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 ? (
|
||||
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => {
|
||||
const duration = run.completed_at && run.started_at
|
||||
? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s`
|
||||
: '-';
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.status === 'success' ? (
|
||||
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
|
||||
) : run.status === 'failure' ? (
|
||||
<Badge variant="destructive">Failed</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Running</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{duration}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate" title={run.output || run.error || ''}>
|
||||
{run.error || run.output || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{Math.ceil(runsTotal / runsLimit) > 1 && runsTask && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {runsPage} of {Math.ceil(runsTotal / runsLimit)}
|
||||
</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" 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" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
|
||||
return (
|
||||
<PaidGate featureName="Auto-Update Policies">
|
||||
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, Clock, Play, CalendarClock } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
|
||||
interface UpdatePreviewImage {
|
||||
service: string;
|
||||
image: string;
|
||||
current_tag: string;
|
||||
next_tag: string | null;
|
||||
has_update: boolean;
|
||||
semver_bump: SemverBump;
|
||||
}
|
||||
|
||||
interface UpdatePreview {
|
||||
stack_name: string;
|
||||
images: UpdatePreviewImage[];
|
||||
summary: {
|
||||
has_update: boolean;
|
||||
primary_image: string | null;
|
||||
current_tag: string | null;
|
||||
next_tag: string | null;
|
||||
semver_bump: SemverBump;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
};
|
||||
rollback_target: string | null;
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
interface StackCard {
|
||||
stack: string;
|
||||
preview: UpdatePreview | null;
|
||||
previewLoaded: boolean;
|
||||
scheduledTask: ScheduledTask | null;
|
||||
applying: boolean;
|
||||
}
|
||||
|
||||
function formatRelative(ts: number | null): string {
|
||||
if (ts == null) return '';
|
||||
const delta = ts - Date.now();
|
||||
if (delta <= 0) return 'due now';
|
||||
const mins = Math.round(delta / 60_000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function formatClock(ts: number | null): string {
|
||||
if (ts == null) return '';
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
|
||||
if (blocked || bump === 'major') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-destructive">
|
||||
<ShieldAlert className="h-3 w-3" strokeWidth={1.5} />
|
||||
Blocked · major
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (bump === 'minor') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-warning">
|
||||
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
|
||||
Review · minor
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (bump === 'patch') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-success/40 bg-success/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-success">
|
||||
<Shield className="h-3 w-3" strokeWidth={1.5} />
|
||||
Safe · patch
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (bump === 'unknown') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
Digest rebuild
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
None
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionDiff({ current, next }: { current: string | null; next: string | null }) {
|
||||
if (!current) return null;
|
||||
const changed = next && next !== current;
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 font-mono text-sm">
|
||||
<span className="text-stat-subtitle">{current}</span>
|
||||
<span className="text-stat-subtitle/60">→</span>
|
||||
<span className={changed ? 'text-brand font-medium' : 'text-stat-subtitle'}>
|
||||
{next ?? current}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackReadinessCard({
|
||||
card,
|
||||
onApply,
|
||||
}: {
|
||||
card: StackCard;
|
||||
onApply: (stack: string) => void;
|
||||
}) {
|
||||
const { stack, preview, previewLoaded, scheduledTask, applying } = card;
|
||||
const loading = !previewLoaded;
|
||||
const failed = previewLoaded && preview === null;
|
||||
const blocked = preview?.summary.blocked ?? false;
|
||||
const bump = preview?.summary.semver_bump ?? 'none';
|
||||
const updatingImageCount = preview?.images.filter(i => i.has_update).length ?? 0;
|
||||
const nextRun = scheduledTask?.next_run_at ?? null;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
|
||||
Stack
|
||||
</span>
|
||||
<span className="font-display italic text-2xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{stack}
|
||||
</span>
|
||||
</div>
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
|
||||
) : failed ? (
|
||||
<div className="font-mono text-xs text-destructive/80">
|
||||
Preview failed. Registry may be unreachable.
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const p = preview!;
|
||||
const blockedReason = p.summary.blocked_reason;
|
||||
return (
|
||||
<>
|
||||
<VersionDiff
|
||||
current={p.summary.current_tag}
|
||||
next={p.summary.next_tag}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle/80">
|
||||
<span>{p.summary.primary_image ?? '-'}</span>
|
||||
{updatingImageCount > 1 && (
|
||||
<span className="text-stat-subtitle/60">
|
||||
· {updatingImageCount} services
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dashed border-card-border pt-3 text-xs text-stat-subtitle/90 leading-relaxed">
|
||||
{p.changelog ?? 'No changelog available from the registry yet.'}
|
||||
</div>
|
||||
|
||||
{blocked && blockedReason && (
|
||||
<div className="rounded border border-destructive/25 bg-destructive/5 px-3 py-2 text-[11px] text-destructive/90">
|
||||
{blockedReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{nextRun ? (
|
||||
<>
|
||||
<CalendarClock className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>Scheduled · <span className="text-stat-value">{formatClock(nextRun)}</span></span>
|
||||
<span className="text-stat-subtitle/70">· {formatRelative(nextRun)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>No schedule</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApply(stack)}
|
||||
disabled={blocked || applying}
|
||||
title={blocked ? (blockedReason ?? undefined) : undefined}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
{applying ? 'Applying...' : 'Apply now'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessHero({
|
||||
total,
|
||||
ready,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: {
|
||||
total: number;
|
||||
ready: number;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const headline = total === 0
|
||||
? 'Everything is up to date'
|
||||
: total === 1
|
||||
? '1 update pending'
|
||||
: `${total} updates pending`;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-brand/25 border-t-brand/35 bg-card shadow-card-bevel">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.10] via-brand/[0.02] to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
|
||||
Fleet readiness
|
||||
</span>
|
||||
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value">
|
||||
{headline}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
||||
{ready} of {total} ready to apply automatically
|
||||
{total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{total > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="font-mono tabular-nums text-2xl text-stat-value">
|
||||
{ready}<span className="text-stat-subtitle/60"> / {total}</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Ready
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label="Recheck registries"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoUpdateReadinessContent() {
|
||||
const { activeNode } = useNodes();
|
||||
const [cards, setCards] = useState<StackCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic token guards against stale setCards from older node-scoped fetches.
|
||||
const loadTokenRef = useRef(0);
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
const token = ++loadTokenRef.current;
|
||||
const currentNodeId = activeNode?.id ?? null;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusRes, tasksRes] = await Promise.all([
|
||||
apiFetch('/image-updates'),
|
||||
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
|
||||
]);
|
||||
if (token !== loadTokenRef.current) return;
|
||||
|
||||
if (!statusRes.ok) {
|
||||
throw new Error('Failed to load image update status');
|
||||
}
|
||||
const statuses = await statusRes.json() as Record<string, boolean>;
|
||||
const stacksWithUpdates = Object.entries(statuses)
|
||||
.filter(([, hasUpdate]) => hasUpdate)
|
||||
.map(([stack]) => stack)
|
||||
.sort();
|
||||
|
||||
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
|
||||
const taskByStack = new Map<string, ScheduledTask>();
|
||||
for (const t of tasks) {
|
||||
// Match tasks targeting this stack on this node. Tasks with node_id=null
|
||||
// are local-node-scoped and only apply when viewing the local node.
|
||||
const matchesNode = currentNodeId != null
|
||||
? (t.node_id === currentNodeId || (t.node_id == null && activeNode?.type === 'local'))
|
||||
: t.node_id == null;
|
||||
if (t.target_type === 'stack' && t.target_id && matchesNode) {
|
||||
const existing = taskByStack.get(t.target_id);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
taskByStack.set(t.target_id, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initial: StackCard[] = stacksWithUpdates.map(stack => ({
|
||||
stack,
|
||||
preview: null,
|
||||
previewLoaded: false,
|
||||
scheduledTask: taskByStack.get(stack) ?? null,
|
||||
applying: false,
|
||||
}));
|
||||
if (token !== loadTokenRef.current) return;
|
||||
setCards(initial);
|
||||
|
||||
const previews = await Promise.all(
|
||||
stacksWithUpdates.map(async (stack) => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stack)}/update-preview`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as UpdatePreview;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (token !== loadTokenRef.current) return;
|
||||
|
||||
setCards(stacksWithUpdates.map((stack, idx) => ({
|
||||
stack,
|
||||
preview: previews[idx],
|
||||
previewLoaded: true,
|
||||
scheduledTask: taskByStack.get(stack) ?? null,
|
||||
applying: false,
|
||||
})));
|
||||
} catch (err) {
|
||||
if (token !== loadTokenRef.current) return;
|
||||
toast.error((err as Error)?.message || 'Failed to load readiness');
|
||||
} finally {
|
||||
if (token === loadTokenRef.current) setLoading(false);
|
||||
}
|
||||
}, [activeNode?.id, activeNode?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
loadReadiness();
|
||||
return () => {
|
||||
// Invalidate any in-flight fetch and cancel pending refresh timers on unmount/node-change.
|
||||
loadTokenRef.current++;
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadReadiness]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
||||
if (res.status === 429) {
|
||||
const data = await res.json().catch(() => ({ error: 'Rate limited' }));
|
||||
toast.warning(data.error ?? 'Please wait before rechecking');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to trigger refresh');
|
||||
return;
|
||||
}
|
||||
toast.success('Checking registries for updates...');
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
loadReadiness();
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to trigger refresh');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [loadReadiness]);
|
||||
|
||||
const handleApply = useCallback(async (stack: string) => {
|
||||
setCards(prev => prev.map(c => c.stack === stack ? { ...c, applying: true } : c));
|
||||
const loadingId = toast.loading(`Applying update to ${stack}...`);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stack)}/update`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Update failed' }));
|
||||
throw new Error(data.error ?? 'Update failed');
|
||||
}
|
||||
toast.success(`${stack} updated successfully`);
|
||||
setCards(prev => prev.filter(c => c.stack !== stack));
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Update failed');
|
||||
setCards(prev => prev.map(c => c.stack === stack ? { ...c, applying: false } : c));
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { total, ready } = useMemo(() => {
|
||||
const t = cards.length;
|
||||
const r = cards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length;
|
||||
return { total: t, ready: r };
|
||||
}, [cards]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-[1600px] mx-auto w-full">
|
||||
<ReadinessHero total={total} ready={ready} refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
|
||||
{loading && cards.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 font-mono text-xs text-stat-subtitle">
|
||||
Loading readiness...
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16">
|
||||
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">
|
||||
Sencho will recheck registries on the scheduler interval.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{cards.map(card => (
|
||||
<StackReadinessCard key={card.stack} card={card} onApply={handleApply} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoUpdateReadinessView() {
|
||||
return (
|
||||
<PaidGate featureName="Auto-Update Readiness">
|
||||
<AutoUpdateReadinessContent />
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { FleetView } from './FleetView';
|
||||
import { AuditLogView } from './AuditLogView';
|
||||
import ScheduledOperationsView from './ScheduledOperationsView';
|
||||
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
|
||||
import AutoUpdateReadinessView from './AutoUpdateReadinessView';
|
||||
import { SecurityHistoryView } from './SecurityHistoryView';
|
||||
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
|
||||
import type { SenchoNavigateDetail } from './NodeManager';
|
||||
@@ -2779,8 +2779,8 @@ export default function EditorLayout() {
|
||||
<AuditLogView />
|
||||
</CapabilityGate>
|
||||
) : activeView === 'auto-updates' ? (
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Policies">
|
||||
<AutoUpdatePoliciesView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||||
<AutoUpdateReadinessView />
|
||||
</CapabilityGate>
|
||||
) : activeView === 'scheduled-ops' ? (
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
@@ -20,11 +20,37 @@ import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' as const },
|
||||
{ value: 'update', label: 'Auto-update Stack', targetType: 'stack' as const },
|
||||
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const },
|
||||
{ value: 'prune', label: 'System Prune', targetType: 'system' as const },
|
||||
{ value: 'scan', label: 'Vulnerability Scan', targetType: 'system' as const },
|
||||
];
|
||||
|
||||
const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [
|
||||
{ key: 'restart', label: 'Restart', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)', actions: ['restart'] },
|
||||
{ key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] },
|
||||
{ key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] },
|
||||
{ key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] },
|
||||
];
|
||||
|
||||
const TIMELINE_WINDOW_HOURS = 24;
|
||||
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
|
||||
|
||||
function formatHourTick(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatRelative(ts: number, now: number): string {
|
||||
const diff = ts - now;
|
||||
if (diff <= 0) return 'now';
|
||||
const mins = Math.round(diff / 60000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
return remMins === 0 ? `in ${hours}h` : `in ${hours}h ${remMins}m`;
|
||||
}
|
||||
|
||||
interface ScheduledOperationsViewProps {
|
||||
filterNodeId?: number | null;
|
||||
onClearFilter?: () => void;
|
||||
@@ -33,6 +59,8 @@ interface ScheduledOperationsViewProps {
|
||||
export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<'timeline' | 'table'>('timeline');
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledTask | null>(null);
|
||||
@@ -71,7 +99,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/scheduled-tasks?exclude_action=update', { localOnly: true });
|
||||
const res = await apiFetch('/scheduled-tasks', { localOnly: true });
|
||||
if (res.ok) {
|
||||
setTasks(await res.json());
|
||||
}
|
||||
@@ -113,6 +141,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
fetchNodes();
|
||||
}, [fetchTasks, fetchStacks, fetchNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (formAction !== 'restart' || !formTargetId) {
|
||||
setAvailableServices([]);
|
||||
@@ -301,6 +334,18 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType;
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
|
||||
const windowEnd = now + TIMELINE_WINDOW_MS;
|
||||
const timelinePills = filteredTasks
|
||||
.filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0)
|
||||
.flatMap(task => (task.next_runs ?? []).map(runAt => ({ task, runAt })))
|
||||
.filter(p => p.runAt >= now && p.runAt <= windowEnd)
|
||||
.sort((a, b) => a.runAt - b.runAt);
|
||||
|
||||
const nextPill = timelinePills[0] ?? null;
|
||||
const hourTicks = Array.from({ length: 6 }, (_, i) => now + (i / 5) * TIMELINE_WINDOW_MS);
|
||||
const windowStartLabel = new Date(now).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
const windowEndLabel = new Date(windowEnd).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
||||
<Card>
|
||||
@@ -311,6 +356,26 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
<CardTitle>Scheduled Operations</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center rounded-md border border-card-border bg-card p-0.5 shadow-btn-glow">
|
||||
<Button
|
||||
variant={view === 'timeline' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 gap-1.5"
|
||||
onClick={() => setView('timeline')}
|
||||
>
|
||||
<CalendarClock className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<span className="text-xs">Timeline</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'table' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 gap-1.5"
|
||||
onClick={() => setView('table')}
|
||||
>
|
||||
<Table2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<span className="text-xs">All tasks</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchTasks} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
@@ -333,7 +398,142 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading && filteredTasks.length === 0 ? (
|
||||
{view === 'timeline' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-6 border-b border-card-border pb-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
|
||||
Next 24 hours
|
||||
</div>
|
||||
<div className="font-display italic text-3xl text-foreground leading-tight">
|
||||
Next <em className="not-italic text-brand">24 hours</em>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stat-subtitle mt-1 tabular-nums">
|
||||
{windowStartLabel} {formatHourTick(now)} → {windowEndLabel} {formatHourTick(windowEnd)}
|
||||
</div>
|
||||
</div>
|
||||
{nextPill ? (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
|
||||
Next
|
||||
</div>
|
||||
<div className="font-mono tabular-nums text-2xl text-brand leading-tight">
|
||||
{formatHourTick(nextPill.runAt)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stat-subtitle mt-1 truncate max-w-[220px]">
|
||||
{nextPill.task.name} · {formatRelative(nextPill.runAt, now)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
|
||||
Next
|
||||
</div>
|
||||
<div className="font-mono tabular-nums text-2xl text-stat-subtitle leading-tight">
|
||||
--:--
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stat-subtitle mt-1">
|
||||
Nothing scheduled
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="space-y-1.5">
|
||||
{TIMELINE_LANES.map(lane => {
|
||||
const lanePills = timelinePills.filter(p => lane.actions.includes(p.task.action));
|
||||
return (
|
||||
<div key={lane.key} className="grid grid-cols-[80px_1fr] items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: lane.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{lane.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative h-8 rounded-md border border-card-border bg-background/40 shadow-[inset_0_1px_2px_0_oklch(0_0_0/0.15)]"
|
||||
>
|
||||
{lanePills.map((pill, idx) => {
|
||||
const leftPct = ((pill.runAt - now) / TIMELINE_WINDOW_MS) * 100;
|
||||
const clamped = Math.max(0, Math.min(100, leftPct));
|
||||
const targetLabel = pill.task.target_type === 'stack'
|
||||
? pill.task.target_id ?? pill.task.name
|
||||
: pill.task.name;
|
||||
return (
|
||||
<button
|
||||
key={`${pill.task.id}-${idx}-${pill.runAt}`}
|
||||
type="button"
|
||||
onClick={() => openRuns(pill.task)}
|
||||
className="absolute top-1/2 -translate-y-1/2 h-6 px-2 rounded-sm text-[10px] font-mono tabular-nums flex items-center gap-1.5 border transition-transform hover:scale-105 hover:z-10 focus:outline-none focus-visible:ring-1 focus-visible:ring-brand"
|
||||
style={{
|
||||
left: `${clamped}%`,
|
||||
backgroundColor: lane.bg,
|
||||
borderColor: lane.color,
|
||||
color: lane.color,
|
||||
transform: clamped > 90
|
||||
? 'translate(-100%, -50%)'
|
||||
: 'translate(0, -50%)',
|
||||
}}
|
||||
title={`${pill.task.name} · ${formatHourTick(pill.runAt)} · ${targetLabel}`}
|
||||
>
|
||||
<span>{formatHourTick(pill.runAt)}</span>
|
||||
<span className="opacity-70 max-w-[100px] truncate">{targetLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Now rail */}
|
||||
<div
|
||||
className="absolute top-0 bottom-6 w-px pointer-events-none"
|
||||
style={{
|
||||
left: 'calc(80px + 0.75rem)',
|
||||
backgroundColor: 'var(--brand)',
|
||||
boxShadow: '0 0 6px 0 var(--brand), 0 0 2px 0 var(--brand)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Axis ticks */}
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3 mt-3">
|
||||
<div />
|
||||
<div className="relative h-4">
|
||||
{hourTicks.map((ts, i) => {
|
||||
const leftPct = (i / 5) * 100;
|
||||
return (
|
||||
<div
|
||||
key={ts}
|
||||
className="absolute top-0 text-[10px] font-mono tabular-nums text-stat-subtitle"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
transform: i === 0 ? 'translateX(0)' : i === 5 ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{formatHourTick(ts)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{timelinePills.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-6 mt-2">
|
||||
Nothing scheduled in the next 24 hours. Toggle to All tasks to see every schedule, or create a new one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : loading && filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
|
||||
Reference in New Issue
Block a user