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:
Anso
2026-04-18 17:48:02 -04:00
committed by GitHub
parent 0bf061a745
commit 95278843cf
16 changed files with 1515 additions and 878 deletions
@@ -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 &ldquo;{deleteTarget?.name}&rdquo;? 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>
);
}
+3 -3
View File
@@ -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">