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
@@ -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>
);
}