mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
15801318d6
* fix: gate alerts and auto-heal routes on stack:edit/stack:read permissions Replace requireAdmin with requirePermission across backend/src/routes/alerts.ts and backend/src/routes/autoHeal.ts, mirroring the stack:read/stack:edit model already used by stacks, blueprints, git sources, and settings. Adds the previously-missing permission gate on the auto-heal history route, and adds ownership-aware deletion for alerts via a new DatabaseService.getStackAlert(id) lookup. * fix: gate image-update fleet, per-stack refresh, and auto-update execute on RBAC permissions Replace requireAdmin with requirePermission/checkPermission across backend/src/routes/imageUpdates.ts (imageUpdatesRouter and autoUpdateRouter), mirroring the permission-aware model already used by alerts and auto-heal. GET /fleet drops its admin gate to match the auth-only read model shared with GET / and /detail. POST /fleet/refresh now requires node:manage. A new route, POST /refresh/:stackName, lets a caller with stack:deploy on that stack trigger a per-stack recheck, distinct from the node-wide POST /refresh. The auto-update executor now pre-checks stack:deploy across every resolved target before any work starts, so a denied stack in a bulk request fails the whole call instead of partially executing; the "*" wildcard additionally requires global stack:deploy up front since it expands to every stack on the node, including the empty case where a per-stack check would otherwise have nothing to gate. * fix: evaluate permission before checks-enabled state in auto-update execute The checks-enabled short-circuit in autoUpdateRouter POST /execute ran before target parsing and before any permission check, so a node with image-update checks disabled returned 200 to any authenticated caller regardless of stack:deploy grants. Move the checks-enabled check to run after the resolved stackNames have cleared requireExactStacks, so permission is always evaluated first. Add coverage: a denied role still gets 403 PERMISSION_DENIED (not the disabled-checks 200) while checks are disabled node-wide, and a scoped-only user whose stack:deploy grant covers every stack on the node is still denied target="*" (the wildcard requires global stack:deploy, per the earlier fix), proving that tradeoff against a real on-disk stack rather than the always- empty fresh test instance. * fix: gate alerts, auto-heal, and image-update controls on frontend permission checks Match the backend RBAC gates for alerts, auto-heal, and per-stack image updates with matching frontend checks, replacing raw isAdmin/node:manage gates with scoped can() calls: - Alerts/Auto-Heal menu items and their keyboard shortcuts now gate on stack:read (canViewMonitor), including the window-level keyboard shortcut handler that previously bypassed the menu item gate entirely. - Check updates now gates on stack:deploy (previously node:manage) and calls the new per-stack POST /image-updates/refresh/:stackName endpoint instead of the node-wide refresh. Since the endpoint runs the recheck synchronously and returns the result directly, the old node-wide /status polling loop is removed in favor of handling the response inline. - StackAlertSheet's alert and auto-heal policy mutation controls gate on stack:edit instead of isAdmin. - The Fleet Image Updates refresh button (mobile and desktop) gates on node:manage, hidden rather than disabled to match the existing convention for node:manage-gated affordances. * fix: cover the stack:edit deny path for StackAlertSheet gates The useAuth mock in StackAlertSheet.test.tsx returned can: () => true unconditionally, so canEditAlerts, canEditAutoHeal, and PolicyRow's canEdit prop were never exercised with a denial. Make the mock per-test-controllable (matching the vi.fn() pattern already used in NodeCard.test.tsx) and add one deny-path test per tab asserting the mutation controls are absent while reads stay visible. Also adds an aria-label to the alert row's delete button so the deny test can assert on its absence, matching the aria-label convention PolicyRow's own toggle/delete controls already use. * fix: surface accurate warnings and loading feedback on stack update checks checkUpdatesForStack ignored the backend's StackRecheckResult outcome and always showed a success toast, even when verification failed or an update is still present. It also gave no feedback while the multi-second per-image registry probe was in flight. Add a loading toast on request start, and branch the result toast on outcome/warning instead of unconditional success. The backend reuses its post-update reconciliation copy for this pre-update discovery check, so the two generic "update command completed" strings are replaced with accurate pre-update wording; a genuine stack-specific warning (e.g. a compose render failure) is still shown as-is. Also update docs/features/rbac.mdx: stack:edit now covers alert and auto-heal management, stack:deploy covers per-stack image-update checks, and the Deployer role description reflects both. * fix: add per-stack cooldown rate limit for image-update recheck route The per-stack POST /refresh/:stackName route bypassed the existing node-wide manual-refresh cooldown. A caller with stack:deploy could hammer the registry with unbounded concurrent recheck calls. Add tryMarkStackRecheck in ImageUpdateService, sharing the same 2-minute cooldown window, keyed per (nodeId, stackName). The route handler returns 429 when denied. The mark is written synchronously before the first await so concurrent calls on the same tick are blocked.
1418 lines
58 KiB
TypeScript
1418 lines
58 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { apiFetch, fetchForNode } from '@/lib/api';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
|
|
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
|
|
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
|
|
import {
|
|
isActionableUpdatePreview,
|
|
isClearedUpdatePreview,
|
|
isLegacyPreview,
|
|
isPreviewUncertain,
|
|
isReviewRequiredUpdatePreview,
|
|
isServiceApplyActionable,
|
|
isTagOnlyAdvisory,
|
|
isVerificationOnlyPreview,
|
|
} from '@/lib/updatePreviewActionability';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
|
import { Masthead, Kicker } from '@/components/mobile/mobile-ui';
|
|
import { ImageSourceMenu } from './ImageSourceMenu';
|
|
import type { ScheduledTask } from '@/types/scheduling';
|
|
import { SERVICE_SCOPED_UPDATE_CAPABILITY } from '@/lib/capabilities';
|
|
import { requestServiceUpdate } from '@/lib/serviceUpdate';
|
|
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
|
|
|
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
|
|
|
interface UpdatePreviewImage {
|
|
service: string;
|
|
image: string;
|
|
current_tag: string;
|
|
next_tag: string | null;
|
|
has_update: boolean;
|
|
digest_update?: boolean;
|
|
tag_update?: boolean;
|
|
semver_bump: SemverBump;
|
|
/** Absent on older remotes; backend uses !== 'not_checkable' for checkability. */
|
|
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
|
|
check_error?: string | null;
|
|
/** This image's own digest-comparison failure; not masked by a confirmed tag update. */
|
|
digest_error?: string | null;
|
|
}
|
|
|
|
type UpdateKind = 'tag' | 'digest' | 'none';
|
|
|
|
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;
|
|
update_kind: UpdateKind;
|
|
blocked: boolean;
|
|
blocked_reason: string | null;
|
|
has_build_services?: boolean;
|
|
rebuild_available?: boolean;
|
|
/** Absent on older remotes; treat missing as non-authoritative. */
|
|
check_status?: 'ok' | 'partial' | 'failed';
|
|
verification_failed?: boolean;
|
|
verification_error?: string | null;
|
|
};
|
|
build_services?: string[];
|
|
rollback_target: string | null;
|
|
changelog: string | null;
|
|
}
|
|
|
|
function declaredServiceCount(preview: UpdatePreview | null | undefined): number {
|
|
if (!preview) return 0;
|
|
const names = new Set<string>();
|
|
for (const img of preview.images) names.add(img.service);
|
|
for (const name of preview.build_services ?? []) names.add(name);
|
|
return names.size;
|
|
}
|
|
|
|
/** Append `: reason` when present, otherwise end the lead-in with a period. */
|
|
function withErrorDetail(lead: string, error: string | null | undefined): string {
|
|
return error ? `${lead}: ${error}` : `${lead}.`;
|
|
}
|
|
|
|
export interface StackCard {
|
|
stack: string;
|
|
nodeId: number;
|
|
preview: UpdatePreview | null;
|
|
previewLoaded: boolean;
|
|
scheduledTask: ScheduledTask | null;
|
|
applying: boolean;
|
|
// True when at least one enabled action='update' scheduled task covers this
|
|
// stack on this node (per-stack row or fleet row). Drives the Auto: Off pill
|
|
// and the hero "ready to apply automatically" count. Manual Apply now is
|
|
// schedule-independent and does not read this field.
|
|
autoUpdateEnabled: boolean;
|
|
// Name of the service currently applying a per-service update on this card,
|
|
// or null when none is in flight. Distinct from `applying` (full-stack).
|
|
applyingService: string | null;
|
|
// Post-Apply verification note when Compose succeeded but clearance could
|
|
// not be confirmed (distinct from a failed preview fetch).
|
|
verificationNote: string | null;
|
|
}
|
|
|
|
interface NodeGroup {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
nodeType: 'local' | 'remote';
|
|
cards: StackCard[];
|
|
}
|
|
|
|
interface FleetUpdateResponse {
|
|
[nodeId: string]: Record<string, boolean>;
|
|
}
|
|
|
|
/**
|
|
* Detection-cadence status for the control instance's scanner, shown by the
|
|
* readiness card: when the last registry check ran, when the next is due, and
|
|
* how long the manual-recheck cooldown has left (ticking once a second).
|
|
*/
|
|
export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatus | null; className?: string }) {
|
|
const [remainingMs, setRemainingMs] = useState(0);
|
|
useEffect(() => {
|
|
setRemainingMs(cadence?.manualCooldownRemainingMs ?? 0);
|
|
}, [cadence]);
|
|
const cooling = remainingMs > 0;
|
|
useEffect(() => {
|
|
if (!cooling) return;
|
|
const id = setInterval(() => setRemainingMs(prev => Math.max(0, prev - 1000)), 1000);
|
|
return () => clearInterval(id);
|
|
}, [cooling]);
|
|
|
|
if (!cadence) return null;
|
|
|
|
const checksOff = cadence.enabled === false;
|
|
const lastChecked = cadence.lastCheckedAt != null ? formatTimeAgo(cadence.lastCheckedAt) : 'never';
|
|
const nextCheck = checksOff
|
|
? 'disabled'
|
|
: cadence.checking
|
|
? 'checking now'
|
|
: cadence.nextCheckAt != null
|
|
? formatRelative(cadence.nextCheckAt)
|
|
: 'not scheduled';
|
|
const cooldown = checksOff
|
|
? 'Detection off'
|
|
: cooling
|
|
? `Recheck available in ${Math.ceil(remainingMs / 1000)}s`
|
|
: 'Recheck ready';
|
|
|
|
return (
|
|
<div className={`flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-stat-subtitle/90 ${className ?? ''}`}>
|
|
<span>Last checked {lastChecked}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>Next check {nextCheck}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>{cooldown}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
reviewRequired,
|
|
uncertain,
|
|
tagOnly,
|
|
}: {
|
|
bump: SemverBump;
|
|
blocked: boolean;
|
|
reviewRequired?: boolean;
|
|
uncertain?: boolean;
|
|
tagOnly?: 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] leading-3 uppercase tracking-[0.18em] text-destructive">
|
|
<ShieldAlert className="h-3 w-3" strokeWidth={1.5} />
|
|
Blocked · major
|
|
</span>
|
|
);
|
|
}
|
|
if (reviewRequired) {
|
|
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] leading-3 uppercase tracking-[0.18em] text-warning">
|
|
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
|
|
Review · unverified
|
|
</span>
|
|
);
|
|
}
|
|
if (uncertain) {
|
|
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] leading-3 uppercase tracking-[0.18em] text-warning">
|
|
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
|
|
Check uncertain
|
|
</span>
|
|
);
|
|
}
|
|
if (tagOnly) {
|
|
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] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
|
Newer tag · edit Compose
|
|
</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] leading-3 uppercase tracking-[0.18em] 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] leading-3 uppercase tracking-[0.18em] 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] leading-3 uppercase tracking-[0.18em] 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] leading-3 uppercase tracking-[0.18em] 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,
|
|
canServiceUpdate = false,
|
|
onApply,
|
|
onApplyService,
|
|
}: {
|
|
card: StackCard;
|
|
canServiceUpdate?: boolean;
|
|
onApply: (stack: string, nodeId: number) => void;
|
|
onApplyService?: (stack: string, nodeId: number, serviceName: string) => void;
|
|
}) {
|
|
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled, verificationNote } = card;
|
|
const loading = !previewLoaded;
|
|
const uncertain = previewLoaded && !!verificationNote;
|
|
const failed = previewLoaded && preview === null && !verificationNote;
|
|
const blocked = preview?.summary.blocked ?? false;
|
|
const bump = preview?.summary.semver_bump ?? 'none';
|
|
const updatingImages = preview?.images.filter(i => i.has_update) ?? [];
|
|
const updatingImageCount = updatingImages.length;
|
|
// Multi-service only: count declared Compose services (image-backed and
|
|
// build-only), not preview.images.length (shared tags collapse that list).
|
|
const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImageCount > 0;
|
|
const nextRun = scheduledTask?.next_run_at ?? null;
|
|
const verificationOnly = isVerificationOnlyPreview(preview);
|
|
const reviewRequired = isReviewRequiredUpdatePreview(preview);
|
|
// Full-stack apply is held for review when another image in the same stack
|
|
// failed digest verification: applying would pull/recreate that image as
|
|
// collateral. Per-service apply targets only images with their own confirmed
|
|
// update, so it is not gated by a different image's verification failure.
|
|
const applyDisabled = !isActionableUpdatePreview(preview)
|
|
|| applying
|
|
|| applyingService !== 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-heading text-2xl leading-tight tracking-tight text-stat-value truncate">
|
|
{stack}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{!autoUpdateEnabled && (
|
|
<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] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
|
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />
|
|
Auto: Off
|
|
</span>
|
|
)}
|
|
{previewLoaded && preview && (
|
|
<RiskBadge
|
|
bump={bump}
|
|
blocked={blocked}
|
|
reviewRequired={reviewRequired}
|
|
uncertain={isPreviewUncertain(preview)}
|
|
tagOnly={isTagOnlyAdvisory(preview)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
|
|
) : uncertain ? (
|
|
<div className="font-mono text-xs text-warning">
|
|
{verificationNote}
|
|
</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;
|
|
const verificationFailed = Boolean(p.summary.verification_failed);
|
|
const verificationError = p.summary.verification_error;
|
|
let applyTitle: string | undefined;
|
|
if (blocked) applyTitle = blockedReason ?? undefined;
|
|
else if (verificationOnly) applyTitle = 'Digest verification failed';
|
|
else if (reviewRequired) applyTitle = 'Another image in this stack failed digest verification; apply the confirmed service individually or resolve verification first.';
|
|
|
|
let headline: ReactNode;
|
|
if (verificationFailed && !p.summary.has_update) {
|
|
headline = (
|
|
<div className="font-mono text-xs text-warning" data-testid="readiness-verification-failed">
|
|
{withErrorDetail('Digest verification failed', verificationError)}
|
|
</div>
|
|
);
|
|
} else if (p.summary.update_kind === 'digest') {
|
|
headline = (
|
|
<div className="flex items-baseline gap-2 font-mono text-sm">
|
|
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
|
|
<span className="text-brand text-[10px] leading-3 uppercase tracking-[0.18em]">
|
|
Rebuild available
|
|
</span>
|
|
</div>
|
|
);
|
|
} else {
|
|
headline = (
|
|
<VersionDiff
|
|
current={p.summary.current_tag}
|
|
next={p.summary.next_tag}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{headline}
|
|
{verificationFailed && p.summary.has_update && (
|
|
<div className="font-mono text-[11px] text-warning" data-testid="readiness-verification-warning">
|
|
{withErrorDetail('Digest check could not be verified', verificationError)}
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
)}
|
|
<ImageSourceMenu imageRef={p.summary.primary_image} />
|
|
</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>
|
|
)}
|
|
|
|
{showServiceApply && (
|
|
<div className="flex flex-col gap-1.5 rounded-md border border-card-border bg-muted/20 p-2">
|
|
{updatingImages.map(img => (
|
|
<div key={img.service} className="flex items-center justify-between gap-2">
|
|
<span className="truncate font-mono text-[11px] text-stat-subtitle">{img.service}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 gap-1 rounded-md px-2 text-[11px]"
|
|
onClick={() => onApplyService?.(stack, nodeId, img.service)}
|
|
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
|
|
>
|
|
{applyingService === img.service ? 'Applying...' : 'Apply'}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</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, nodeId)}
|
|
disabled={applyDisabled}
|
|
title={applyTitle}
|
|
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,
|
|
nodeCount,
|
|
refreshing,
|
|
onRefresh,
|
|
canRefresh,
|
|
unresolvedChecks = false,
|
|
detectionDisabled = false,
|
|
}: {
|
|
total: number;
|
|
ready: number;
|
|
nodeCount: number;
|
|
refreshing: boolean;
|
|
onRefresh: () => void;
|
|
canRefresh: boolean;
|
|
unresolvedChecks?: boolean;
|
|
detectionDisabled?: boolean;
|
|
}) {
|
|
const headline = detectionDisabled
|
|
? 'Image update detection disabled'
|
|
: total === 0
|
|
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
|
|
: total === 1
|
|
? '1 update pending'
|
|
: `${total} updates pending`;
|
|
const acrossNodes = nodeCount > 1
|
|
? ` across ${nodeCount} nodes`
|
|
: nodeCount === 1
|
|
? ' across 1 node'
|
|
: '';
|
|
|
|
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-heading 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{acrossNodes}
|
|
{total - ready > 0 ? ` · ${total - ready} need a schedule or review` : ''}
|
|
</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>
|
|
)}
|
|
{canRefresh && (
|
|
<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 NodeGroupSection({
|
|
group,
|
|
canServiceUpdate,
|
|
onApply,
|
|
onApplyService,
|
|
}: {
|
|
group: NodeGroup;
|
|
canServiceUpdate: boolean;
|
|
onApply: (stack: string, nodeId: number) => void;
|
|
onApplyService: (stack: string, nodeId: number, serviceName: string) => void;
|
|
}) {
|
|
const TypeIcon = group.nodeType === 'local' ? Monitor : Globe;
|
|
const stackCount = group.cards.length;
|
|
return (
|
|
<section className="flex flex-col gap-4">
|
|
<div className="flex items-baseline gap-3 border-b border-card-border/60 pb-2">
|
|
<TypeIcon className="h-4 w-4 text-stat-subtitle self-center" strokeWidth={1.5} aria-hidden="true" />
|
|
<span className="font-heading text-xl leading-tight tracking-tight text-stat-value truncate">
|
|
{group.nodeName}
|
|
</span>
|
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 self-center">
|
|
{group.nodeType}
|
|
</Badge>
|
|
<span className="font-mono text-[11px] text-stat-subtitle/80">
|
|
{stackCount} {stackCount === 1 ? 'stack' : 'stacks'}
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3">
|
|
{group.cards.map(card => (
|
|
<StackReadinessCard
|
|
key={`${card.nodeId}::${card.stack}`}
|
|
card={card}
|
|
canServiceUpdate={canServiceUpdate}
|
|
onApply={onApply}
|
|
onApplyService={onApplyService}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// --- mobile (<md) bespoke pieces ---------------------------------------------
|
|
|
|
/** One-up readiness card for the phone screen. Reuses RiskBadge + VersionDiff
|
|
* and the same apply/disabled logic as the desktop card. Exported for tests. */
|
|
export function MobileReadinessCard({
|
|
card,
|
|
canServiceUpdate = false,
|
|
onApply,
|
|
onApplyService,
|
|
}: {
|
|
card: StackCard;
|
|
canServiceUpdate?: boolean;
|
|
onApply: (stack: string, nodeId: number) => void;
|
|
onApplyService?: (stack: string, nodeId: number, serviceName: string) => void;
|
|
}) {
|
|
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled, verificationNote } = card;
|
|
const uncertain = previewLoaded && !!verificationNote;
|
|
const failed = previewLoaded && preview === null && !verificationNote;
|
|
const blocked = preview?.summary.blocked ?? false;
|
|
const bump = preview?.summary.semver_bump ?? 'none';
|
|
const updatingImages = preview?.images.filter(i => i.has_update) ?? [];
|
|
const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImages.length > 0;
|
|
const nextRun = scheduledTask?.next_run_at ?? null;
|
|
const verificationOnly = isVerificationOnlyPreview(preview);
|
|
const reviewRequired = isReviewRequiredUpdatePreview(preview);
|
|
const applyDisabled = !isActionableUpdatePreview(preview)
|
|
|| applying
|
|
|| applyingService !== null;
|
|
const changelog = preview?.changelog ?? 'No changelog available from the registry yet.';
|
|
const dot = changelog.indexOf('.');
|
|
const lead = dot > 0 ? changelog.slice(0, dot + 1) : '';
|
|
const rest = dot > 0 ? changelog.slice(dot + 1) : changelog;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-[10px] rounded-xl border border-card-border border-t-card-border-top bg-card p-[14px] shadow-card-bevel">
|
|
<div className="flex items-start justify-between gap-[10px]">
|
|
<div className="min-w-0 flex-1">
|
|
<Kicker>stack</Kicker>
|
|
<div className="mt-px truncate font-heading text-[23px] leading-[26px] text-stat-value">{stack}</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{!autoUpdateEnabled && (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-card-border px-2 py-[3px] font-mono text-[10px] uppercase tracking-[0.1em] text-stat-subtitle">
|
|
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />Auto: Off
|
|
</span>
|
|
)}
|
|
{previewLoaded && preview && (
|
|
<RiskBadge
|
|
bump={bump}
|
|
blocked={blocked}
|
|
reviewRequired={reviewRequired}
|
|
uncertain={isPreviewUncertain(preview)}
|
|
tagOnly={isTagOnlyAdvisory(preview)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!previewLoaded ? (
|
|
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
|
|
) : uncertain ? (
|
|
<div className="font-mono text-xs text-warning">{verificationNote}</div>
|
|
) : failed ? (
|
|
<div className="font-mono text-xs text-destructive/80">Preview failed. Registry may be unreachable.</div>
|
|
) : (() => {
|
|
const p = preview!;
|
|
const verificationFailed = Boolean(p.summary.verification_failed);
|
|
const verificationError = p.summary.verification_error;
|
|
|
|
let headline: ReactNode;
|
|
if (verificationFailed && !p.summary.has_update) {
|
|
headline = (
|
|
<div className="font-mono text-xs text-warning" data-testid="readiness-verification-failed">
|
|
{withErrorDetail('Digest verification failed', verificationError)}
|
|
</div>
|
|
);
|
|
} else if (p.summary.update_kind === 'digest') {
|
|
headline = (
|
|
<div className="flex items-baseline gap-2 font-mono text-[13px]">
|
|
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
|
|
<span className="text-[10px] uppercase tracking-[0.12em] text-brand">Rebuild available</span>
|
|
</div>
|
|
);
|
|
} else {
|
|
headline = <VersionDiff current={p.summary.current_tag} next={p.summary.next_tag} />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{headline}
|
|
{verificationFailed && p.summary.has_update && (
|
|
<div className="font-mono text-[11px] text-warning" data-testid="readiness-verification-warning">
|
|
{withErrorDetail('Digest check could not be verified', verificationError)}
|
|
</div>
|
|
)}
|
|
<div className="truncate font-mono text-[11px] text-stat-subtitle">{p.summary.primary_image ?? '-'}</div>
|
|
<div className="border-t border-dashed border-card-border pt-[9px] text-[12.5px] leading-[18px] text-stat-subtitle">
|
|
{lead && <b className="text-stat-title">{lead}</b>}{rest}
|
|
</div>
|
|
{showServiceApply && (
|
|
<div className="flex flex-col gap-1.5 rounded-md border border-card-border bg-muted/20 p-2">
|
|
{updatingImages.map(img => (
|
|
<div key={img.service} className="flex items-center justify-between gap-2">
|
|
<span className="truncate font-mono text-[11px] text-stat-subtitle">{img.service}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 gap-1 rounded-md px-2 text-[11px]"
|
|
onClick={() => onApplyService?.(stack, nodeId, img.service)}
|
|
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
|
|
>
|
|
{applyingService === img.service ? 'Applying...' : 'Apply'}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between gap-[10px] pt-0.5">
|
|
<span className={`font-mono text-[11px] ${blocked || reviewRequired ? 'text-destructive' : 'text-stat-subtitle'}`}>
|
|
{nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)}</> : (blocked || reviewRequired ? 'Held for review' : 'No schedule')}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant={blocked || verificationOnly || reviewRequired ? 'outline' : 'default'}
|
|
onClick={() => onApply(stack, nodeId)}
|
|
disabled={applyDisabled}
|
|
title={reviewRequired ? 'Another image in this stack failed digest verification; apply the confirmed service individually or resolve verification first.' : 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>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileNodeSection({
|
|
group,
|
|
canServiceUpdate,
|
|
onApply,
|
|
onApplyService,
|
|
}: {
|
|
group: NodeGroup;
|
|
canServiceUpdate: boolean;
|
|
onApply: (stack: string, nodeId: number) => void;
|
|
onApplyService: (stack: string, nodeId: number, serviceName: string) => void;
|
|
}) {
|
|
return (
|
|
<section>
|
|
<div className="mb-[13px] flex items-baseline gap-2 border-b border-hairline pb-2">
|
|
<span className="truncate font-heading text-[19px] leading-tight text-stat-value">{group.nodeName}</span>
|
|
<span className="shrink-0 rounded-[5px] border border-card-border px-1.5 py-px font-mono text-[10px] uppercase tracking-[0.1em] text-stat-subtitle">{group.nodeType}</span>
|
|
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{group.cards.length} {group.cards.length === 1 ? 'stack' : 'stacks'}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-3">
|
|
{group.cards.map(card => (
|
|
<MobileReadinessCard
|
|
key={`${card.nodeId}::${card.stack}`}
|
|
card={card}
|
|
canServiceUpdate={canServiceUpdate}
|
|
onApply={onApply}
|
|
onApplyService={onApplyService}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Advisory for local-node stacks whose latest image-update check could not
|
|
* determine status. These never appear in the card grid (which lists only
|
|
* confirmed updates), so without this they would be invisible here.
|
|
*/
|
|
function CheckFailuresNotice({ failures }: { failures: { stack: string; reason: string | null }[] }) {
|
|
if (failures.length === 0) return null;
|
|
return (
|
|
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3">
|
|
<div className="flex items-center gap-2 font-mono text-[11px] text-warning">
|
|
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
|
{failures.length} stack{failures.length !== 1 ? 's' : ''} could not be checked
|
|
</div>
|
|
<ul className="mt-1.5 space-y-0.5 pl-5">
|
|
{failures.map(f => (
|
|
<li key={f.stack} className="font-mono text-[11px] text-stat-subtitle">
|
|
<span className="text-stat-value">{f.stack}</span>{f.reason ? `: ${f.reason}` : ''}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AutoUpdateReadinessProps {
|
|
/** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */
|
|
headerActions?: ReactNode;
|
|
}
|
|
|
|
function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) {
|
|
const isMobile = useIsMobile();
|
|
const { runWithLog } = useDeployFeedback();
|
|
const { can } = useAuth();
|
|
const canRefreshFleet = can('node:manage');
|
|
const { nodes, nodeMeta, refreshNodeMeta } = useNodes();
|
|
const [groups, setGroups] = useState<NodeGroup[]>([]);
|
|
const [reachableNodeCount, setReachableNodeCount] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [cadence, setCadence] = useState<ImageUpdateStatus | null>(null);
|
|
// Local-node stacks whose latest check could not determine status. The fleet
|
|
// list only shows stacks with a confirmed update, so without this a stack
|
|
// whose checks all fail would silently vanish from this view.
|
|
const [checkFailures, setCheckFailures] = useState<{ stack: string; reason: string | null }[]>([]);
|
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Monotonic token guards against stale setGroups from older fetches.
|
|
const loadTokenRef = useRef(0);
|
|
// Separate token for the cadence fetch: a slow initial /status must not
|
|
// overwrite the fresher status a Recheck just loaded, and neither may set
|
|
// state after unmount.
|
|
const cadenceTokenRef = useRef(0);
|
|
// Holds the latest nodes array so loadReadiness can reference it without
|
|
// re-firing every time NodeContext rebuilds the array on a meta refresh.
|
|
const nodesRef = useRef(nodes);
|
|
nodesRef.current = nodes;
|
|
|
|
// Stable signature: only changes when membership or node identity actually
|
|
// changes, not when NodeContext reissues the same logical list.
|
|
const nodesSignature = useMemo(
|
|
() => nodes.map(n => `${n.id}:${n.type}:${n.status}`).sort().join('|'),
|
|
[nodes],
|
|
);
|
|
|
|
const localNodeId = useMemo(() => nodes.find(n => n.type === 'local')?.id ?? null, [nodes]);
|
|
const onlineNodeCount = useMemo(() => nodes.filter(n => n.status === 'online').length, [nodes]);
|
|
|
|
const loadReadiness = useCallback(async () => {
|
|
const token = ++loadTokenRef.current;
|
|
setLoading(true);
|
|
try {
|
|
const [statusRes, tasksRes, detailRes] = await Promise.all([
|
|
apiFetch('/image-updates/fleet', { localOnly: true }),
|
|
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
|
|
apiFetch('/image-updates/detail', { localOnly: true }),
|
|
]);
|
|
if (token !== loadTokenRef.current) return;
|
|
|
|
if (!statusRes.ok) {
|
|
throw new Error('Failed to load fleet update status');
|
|
}
|
|
const fleetStatus = await statusRes.json() as FleetUpdateResponse;
|
|
setReachableNodeCount(Object.keys(fleetStatus).length);
|
|
|
|
// Local-node check failures: surfaced separately because the fleet map is
|
|
// boolean and the card grid only lists stacks with a confirmed update.
|
|
// Sticky has_update during a failed check is not a verified rebuild, so those
|
|
// stacks stay in the advisory only.
|
|
let failedLocalStacks = new Set<string>();
|
|
if (detailRes.ok) {
|
|
const detail = await detailRes.json() as Record<string, StackUpdateInfo>;
|
|
const failures = Object.entries(detail)
|
|
.filter(([, info]) => info.checkStatus === 'failed')
|
|
.map(([stack, info]) => ({ stack, reason: info.lastError }))
|
|
.sort((a, b) => a.stack.localeCompare(b.stack));
|
|
failedLocalStacks = new Set(failures.map(f => f.stack));
|
|
setCheckFailures(failures);
|
|
} else {
|
|
// Clear stale failures rather than persist them across a load, but log:
|
|
// an empty advisory must not silently stand in for "detail unavailable".
|
|
console.error('[AutoUpdateReadiness] /image-updates/detail failed:', detailRes.status);
|
|
setCheckFailures([]);
|
|
}
|
|
|
|
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
|
|
// A stack is "covered" by an enabled action='update' row when either
|
|
// a per-stack row targets it or a fleet row targets its node. We pick
|
|
// the earliest next-run covering task so the readiness card renders
|
|
// the next-run time accurately for both shapes.
|
|
const taskByNodeStack = new Map<string, ScheduledTask>();
|
|
const fleetTaskByNode = new Map<number, ScheduledTask>();
|
|
for (const t of tasks) {
|
|
if (!t.enabled) continue;
|
|
// The fetch URL filters on action=update; this guard makes the
|
|
// coverage check robust against a future regression there.
|
|
if (t.action !== 'update') continue;
|
|
const taskNodeId = t.node_id ?? localNodeId;
|
|
if (taskNodeId == null) continue;
|
|
if (t.target_type === 'fleet') {
|
|
const existing = fleetTaskByNode.get(taskNodeId);
|
|
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
|
fleetTaskByNode.set(taskNodeId, t);
|
|
}
|
|
} else if (t.target_type === 'stack' && t.target_id) {
|
|
const key = `${taskNodeId}::${t.target_id}`;
|
|
const existing = taskByNodeStack.get(key);
|
|
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
|
taskByNodeStack.set(key, t);
|
|
}
|
|
}
|
|
}
|
|
|
|
const flatPairs: { nodeId: number; stack: string }[] = [];
|
|
const initialGroups: NodeGroup[] = [];
|
|
const currentNodes = nodesRef.current;
|
|
for (const [nodeIdStr, stackMap] of Object.entries(fleetStatus)) {
|
|
const nodeId = Number(nodeIdStr);
|
|
const node = currentNodes.find(n => n.id === nodeId);
|
|
if (!node) continue;
|
|
const stacks = Object.entries(stackMap)
|
|
.filter(([stack, hasUpdate]) =>
|
|
hasUpdate && !(node.type === 'local' && failedLocalStacks.has(stack)),
|
|
)
|
|
.map(([stack]) => stack)
|
|
.sort();
|
|
if (stacks.length === 0) continue;
|
|
const cards: StackCard[] = stacks.map(stack => {
|
|
flatPairs.push({ nodeId, stack });
|
|
const stackTask = taskByNodeStack.get(`${nodeId}::${stack}`) ?? null;
|
|
const fleetTask = fleetTaskByNode.get(nodeId) ?? null;
|
|
// Prefer whichever covering task fires next.
|
|
// Earliest next-run wins; on a tie, the per-stack row beats the
|
|
// fleet row so the user sees the more specific schedule.
|
|
const scheduledTask = stackTask && fleetTask
|
|
? ((stackTask.next_run_at ?? Infinity) <= (fleetTask.next_run_at ?? Infinity) ? stackTask : fleetTask)
|
|
: (stackTask ?? fleetTask);
|
|
return {
|
|
stack,
|
|
nodeId,
|
|
preview: null,
|
|
previewLoaded: false,
|
|
scheduledTask,
|
|
applying: false,
|
|
applyingService: null,
|
|
autoUpdateEnabled: scheduledTask !== null,
|
|
verificationNote: null,
|
|
};
|
|
});
|
|
initialGroups.push({
|
|
nodeId,
|
|
nodeName: node.name,
|
|
nodeType: node.type,
|
|
cards,
|
|
});
|
|
}
|
|
initialGroups.sort((a, b) => {
|
|
if (a.nodeType !== b.nodeType) return a.nodeType === 'local' ? -1 : 1;
|
|
return a.nodeName.localeCompare(b.nodeName);
|
|
});
|
|
|
|
if (token !== loadTokenRef.current) return;
|
|
setGroups(initialGroups);
|
|
// Resolve service-scoped-update capability for every node in this fleet
|
|
// view (not just the active one) so per-service Apply can gate on each
|
|
// card's own node; skips nodes whose meta is already cached.
|
|
for (const g of initialGroups) {
|
|
void refreshNodeMeta(g.nodeId);
|
|
}
|
|
|
|
const previews = await Promise.all(
|
|
flatPairs.map(async ({ nodeId, stack }) => {
|
|
try {
|
|
const result = await fetchUpdatePreview(stack, {
|
|
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
|
|
});
|
|
if (!result.ok || !result.preview) return null;
|
|
return result.preview as UpdatePreview;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
if (token !== loadTokenRef.current) return;
|
|
|
|
const previewByKey = new Map<string, UpdatePreview | null>();
|
|
flatPairs.forEach((pair, idx) => {
|
|
previewByKey.set(`${pair.nodeId}::${pair.stack}`, previews[idx]);
|
|
});
|
|
|
|
const previewAdvisory: { stack: string; reason: string | null }[] = [];
|
|
const groupsWithPreview = initialGroups
|
|
.map(g => {
|
|
const cards: StackCard[] = [];
|
|
for (const c of g.cards) {
|
|
const preview = previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null;
|
|
if (isVerificationOnlyPreview(preview)) {
|
|
previewAdvisory.push({
|
|
stack: g.nodeType === 'remote' ? `${c.stack} (${g.nodeName})` : c.stack,
|
|
reason: preview?.summary.verification_error ?? 'Digest verification failed',
|
|
});
|
|
continue;
|
|
}
|
|
// Sticky fleet booleans can outlive a successful no-update preview.
|
|
// Drop those cards without treating them as check failures.
|
|
if (isClearedUpdatePreview(preview)) continue;
|
|
// A legacy preview (missing verification_failed entirely) is kept
|
|
// rather than cleared, but that alone renders as a pending card
|
|
// with nothing to explain it; flag why in the advisory too. Skip
|
|
// this when the preview is already actionable on its own terms
|
|
// (the remote's own has_update/rebuild_available): the card
|
|
// already speaks for itself, and pairing it with "could not be
|
|
// checked" would contradict the Apply affordance right next to it.
|
|
if (isLegacyPreview(preview) && !isActionableUpdatePreview(preview)) {
|
|
previewAdvisory.push({
|
|
stack: g.nodeType === 'remote' ? `${c.stack} (${g.nodeName})` : c.stack,
|
|
reason: 'This node\'s preview predates digest verification reporting',
|
|
});
|
|
}
|
|
cards.push({ ...c, preview, previewLoaded: true });
|
|
}
|
|
return { ...g, cards };
|
|
})
|
|
.filter(g => g.cards.length > 0);
|
|
|
|
if (previewAdvisory.length > 0) {
|
|
setCheckFailures(prev => {
|
|
const byStack = new Map(prev.map(f => [f.stack, f]));
|
|
for (const entry of previewAdvisory) {
|
|
if (!byStack.has(entry.stack)) byStack.set(entry.stack, entry);
|
|
}
|
|
return [...byStack.values()].sort((a, b) => a.stack.localeCompare(b.stack));
|
|
});
|
|
}
|
|
|
|
setGroups(groupsWithPreview);
|
|
} catch (err) {
|
|
if (token !== loadTokenRef.current) return;
|
|
toast.error((err as Error)?.message || 'Failed to load readiness');
|
|
} finally {
|
|
if (token === loadTokenRef.current) setLoading(false);
|
|
}
|
|
}, [localNodeId, refreshNodeMeta]);
|
|
|
|
// Detection-cadence status for the control instance (localOnly): the readiness
|
|
// list is fleet-wide, but the cadence shown by the card is this instance's own
|
|
// scanner, configured in Settings. Each node runs its own scanner.
|
|
const loadCadence = useCallback(async () => {
|
|
const token = ++cadenceTokenRef.current;
|
|
try {
|
|
const res = await apiFetch('/image-updates/status', { localOnly: true });
|
|
if (!res.ok) return;
|
|
const data = await res.json() as ImageUpdateStatus;
|
|
// Drop the result if a newer cadence load started, or the view unmounted,
|
|
// while this one was in flight.
|
|
if (token === cadenceTokenRef.current) setCadence(data);
|
|
} catch (e) {
|
|
console.error('[AutoUpdate] failed to load image-update cadence status', e);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (nodesSignature === '') return;
|
|
loadReadiness();
|
|
void loadCadence();
|
|
return () => {
|
|
// Invalidate any in-flight fetch and cancel pending refresh timers on unmount.
|
|
loadTokenRef.current++;
|
|
cadenceTokenRef.current++;
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current);
|
|
refreshTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [loadReadiness, loadCadence, nodesSignature]);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
const res = await apiFetch('/image-updates/fleet/refresh', { method: 'POST', localOnly: true });
|
|
if (!res.ok) {
|
|
toast.error('Failed to trigger refresh');
|
|
return;
|
|
}
|
|
const data = await res.json() as { triggered: number[]; rateLimited: number[]; failed: number[] };
|
|
const tCount = data.triggered.length;
|
|
const rCount = data.rateLimited.length;
|
|
const fCount = data.failed.length;
|
|
// Re-seed the cadence strip so the manual-cooldown countdown reflects the
|
|
// recheck we just fired.
|
|
void loadCadence();
|
|
if (tCount > 0) {
|
|
toast.success(`Rechecking ${tCount} ${tCount === 1 ? 'node' : 'nodes'}...`);
|
|
}
|
|
if (rCount > 0) {
|
|
toast.warning(`${rCount} ${rCount === 1 ? 'node is' : 'nodes are'} rate-limited; try again shortly`);
|
|
}
|
|
if (fCount > 0) {
|
|
toast.error(`${fCount} ${fCount === 1 ? 'node' : 'nodes'} failed to refresh`);
|
|
}
|
|
if (tCount === 0 && rCount === 0 && fCount === 0) {
|
|
toast.info('No reachable nodes to refresh');
|
|
return;
|
|
}
|
|
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, loadCadence]);
|
|
|
|
// Nodes that advertise service-scoped updates, resolved per node (not just
|
|
// the active one) since this view spans the whole fleet.
|
|
const serviceScopedNodeIds = useMemo(
|
|
() => new Set(
|
|
Array.from(nodeMeta.entries())
|
|
.filter(([, meta]) => meta.capabilities.includes(SERVICE_SCOPED_UPDATE_CAPABILITY))
|
|
.map(([id]) => id),
|
|
),
|
|
[nodeMeta],
|
|
);
|
|
|
|
const handleApplyService = useCallback(async (stack: string, nodeId: number, serviceName: string) => {
|
|
const setCardField = (predicate: (c: StackCard) => boolean, patch: Partial<StackCard>) =>
|
|
setGroups(prev => prev.map(g => ({
|
|
...g,
|
|
cards: g.cards.map(c => predicate(c) ? { ...c, ...patch } : c),
|
|
})));
|
|
|
|
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applyingService: serviceName });
|
|
const loadingId = toast.loading(`Applying update to "${serviceName}" in ${stack}...`);
|
|
try {
|
|
await runWithLog({ stackName: stack, action: 'update', nodeId, serviceName }, async (started, ds) => {
|
|
await started;
|
|
const result = await requestServiceUpdate({
|
|
nodeId, stackName: stack, serviceName, mode: 'update', deploySessionId: ds,
|
|
});
|
|
if (!result.ok) {
|
|
toast.error(result.error);
|
|
return { ok: false as const, errorMessage: result.error };
|
|
}
|
|
if (result.recheckWarning) toast.info(result.recheckWarning);
|
|
if (result.healthGateId && result.observing) {
|
|
toast.info(`Service "${serviceName}" updated. Verifying health...`);
|
|
} else {
|
|
toast.success(`Service "${serviceName}" updated successfully`);
|
|
}
|
|
// Reload authoritative preview so summary / Apply affordances stay accurate.
|
|
try {
|
|
const result = await fetchUpdatePreview(stack, {
|
|
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
|
|
});
|
|
if (result.ok && result.preview) {
|
|
const next = result.preview as UpdatePreview;
|
|
if (isAuthoritativeNegativePreview(next)) {
|
|
setGroups(prev => prev
|
|
.map(g => g.nodeId === nodeId
|
|
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
|
|
: g)
|
|
.filter(g => g.cards.length > 0));
|
|
} else {
|
|
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
|
|
}
|
|
} else {
|
|
console.error(`[AutoUpdateReadinessView] post-apply update-preview failed (${result.status})`);
|
|
}
|
|
} catch (err) {
|
|
console.error('[AutoUpdateReadinessView] post-apply update-preview refresh failed', err);
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
healthGateId: result.observing ? result.healthGateId : null,
|
|
recoveryId: result.recoveryId,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Update failed');
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applyingService: null });
|
|
}
|
|
}, [runWithLog]);
|
|
|
|
const handleApply = useCallback(async (stack: string, nodeId: number) => {
|
|
const setCardField = (predicate: (c: StackCard) => boolean, patch: Partial<StackCard>) =>
|
|
setGroups(prev => prev.map(g => ({
|
|
...g,
|
|
cards: g.cards.map(c => predicate(c) ? { ...c, ...patch } : c),
|
|
})));
|
|
const matchCard = (c: StackCard) => c.stack === stack && c.nodeId === nodeId;
|
|
const removeCard = () => setGroups(prev => prev
|
|
.map(g => g.nodeId === nodeId
|
|
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
|
|
: g)
|
|
.filter(g => g.cards.length > 0));
|
|
const retainPreviewFailed = () => setCardField(matchCard, {
|
|
applying: false,
|
|
preview: null,
|
|
previewLoaded: true,
|
|
verificationNote: null,
|
|
});
|
|
const retainUncertain = (note: string) => setCardField(matchCard, {
|
|
applying: false,
|
|
preview: null,
|
|
previewLoaded: true,
|
|
verificationNote: note,
|
|
});
|
|
|
|
setCardField(matchCard, { applying: true, verificationNote: null });
|
|
const loadingId = toast.loading(`Applying update to ${stack}...`);
|
|
try {
|
|
const res = await fetchForNode(
|
|
`/stacks/${encodeURIComponent(stack)}/update`,
|
|
nodeId,
|
|
{ method: 'POST' },
|
|
);
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({ error: 'Update failed' }));
|
|
throw new Error(data.error ?? 'Update failed');
|
|
}
|
|
const body = await res.json().catch(() => ({})) as { recheckWarning?: unknown };
|
|
const recheckWarning = typeof body.recheckWarning === 'string' ? body.recheckWarning : undefined;
|
|
if (recheckWarning) toast.info(recheckWarning);
|
|
else toast.success(`${stack} updated successfully`);
|
|
|
|
// Authoritative live preview decides card removal. When it disagrees with
|
|
// a backend recheckWarning (preview cleared, persisted check uncertain),
|
|
// keep an uncertain card so Fleet does not diverge from the sidebar.
|
|
try {
|
|
const previewRes = await fetchForNode(
|
|
`/stacks/${encodeURIComponent(stack)}/update-preview`,
|
|
nodeId,
|
|
);
|
|
if (!previewRes.ok) {
|
|
retainPreviewFailed();
|
|
return;
|
|
}
|
|
const next = await previewRes.json() as UpdatePreview;
|
|
if (typeof next?.summary?.has_update !== 'boolean') {
|
|
retainPreviewFailed();
|
|
return;
|
|
}
|
|
// Drop only when the live preview proves nothing remains (tag-only
|
|
// advisories stay pending via isClearedUpdatePreview).
|
|
const cleared = isAuthoritativeNegativePreview(next) || isClearedUpdatePreview(next);
|
|
if (!cleared) {
|
|
if (next.summary.has_update && !recheckWarning) {
|
|
toast.info(
|
|
'The update command completed, but Sencho still detects an available image update.',
|
|
);
|
|
}
|
|
setCardField(matchCard, {
|
|
applying: false,
|
|
preview: next,
|
|
previewLoaded: true,
|
|
verificationNote: recheckWarning ?? null,
|
|
});
|
|
return;
|
|
}
|
|
if (recheckWarning) {
|
|
retainUncertain(recheckWarning);
|
|
return;
|
|
}
|
|
removeCard();
|
|
} catch (previewErr) {
|
|
console.error('[AutoUpdate] post-Apply preview reconciliation failed', previewErr);
|
|
retainPreviewFailed();
|
|
}
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Update failed');
|
|
setCardField(matchCard, { applying: false });
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
}
|
|
}, []);
|
|
|
|
const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]);
|
|
const { total, ready } = useMemo(() => {
|
|
const t = flatCards.length;
|
|
// Schedule-covered and actionable (confirmed update/rebuild, not blocked).
|
|
const r = flatCards.filter(c =>
|
|
c.autoUpdateEnabled
|
|
&& c.previewLoaded
|
|
&& c.preview !== null
|
|
&& isActionableUpdatePreview(c.preview),
|
|
).length;
|
|
return { total: t, ready: r };
|
|
}, [flatCards]);
|
|
|
|
const showPartialBanner = reachableNodeCount != null
|
|
&& onlineNodeCount > 0
|
|
&& reachableNodeCount < onlineNodeCount;
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<Masthead
|
|
kicker="fleet · updates"
|
|
state={cadence?.enabled === false
|
|
? 'Disabled'
|
|
: total === 0
|
|
? (checkFailures.length > 0 ? 'No verified updates' : 'Up to date')
|
|
: `${total} pending`}
|
|
stateTone={cadence?.enabled === false
|
|
? 'brand'
|
|
: total === 0 && checkFailures.length === 0 ? 'success' : 'warning'}
|
|
live={total > 0 && cadence?.enabled !== false}
|
|
meta={cadence?.enabled === false
|
|
? 'image update detection off'
|
|
: total > 0
|
|
? `${ready} ready · ${total - ready} in review`
|
|
: (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')}
|
|
right={headerActions}
|
|
/>
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-4 [&>*+*]:mt-4">
|
|
<div className="flex justify-end">
|
|
{canRefreshFleet && (
|
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || cadence?.enabled === false} aria-label="Recheck registries" className="gap-1.5">
|
|
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} strokeWidth={1.5} aria-hidden="true" />
|
|
Recheck
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<CadenceStrip cadence={cadence} />
|
|
|
|
{showPartialBanner && (
|
|
<div className="font-mono text-[11px] text-stat-subtitle">
|
|
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
|
|
</div>
|
|
)}
|
|
<CheckFailuresNotice failures={checkFailures} />
|
|
{loading && groups.length === 0 ? (
|
|
<div className="flex items-center justify-center py-16 font-mono text-xs text-stat-subtitle">Loading readiness...</div>
|
|
) : groups.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 text-center">
|
|
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
|
|
<div className="font-display italic text-xl text-stat-value">
|
|
{cadence?.enabled === false
|
|
? 'Detection disabled'
|
|
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
|
</div>
|
|
<div className="font-mono text-[11px] text-stat-subtitle">
|
|
{cadence?.enabled === false
|
|
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
|
|
: checkFailures.length > 0
|
|
? 'Review the unresolved checks above, then recheck.'
|
|
: 'Sencho rechecks registries on the configured interval.'}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
groups.map(group => (
|
|
<MobileNodeSection
|
|
key={group.nodeId}
|
|
group={group}
|
|
canServiceUpdate={serviceScopedNodeIds.has(group.nodeId)}
|
|
onApply={handleApply}
|
|
onApplyService={handleApplyService}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 p-6 max-w-[1600px] mx-auto w-full">
|
|
<ReadinessHero
|
|
total={total}
|
|
ready={ready}
|
|
nodeCount={groups.length}
|
|
refreshing={refreshing}
|
|
onRefresh={handleRefresh}
|
|
canRefresh={canRefreshFleet}
|
|
unresolvedChecks={checkFailures.length > 0}
|
|
detectionDisabled={cadence?.enabled === false}
|
|
/>
|
|
|
|
<CadenceStrip cadence={cadence} className="-mt-3 pl-7" />
|
|
|
|
{showPartialBanner && (
|
|
<div className="font-mono text-[11px] text-stat-subtitle/90 -mt-3 pl-7">
|
|
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
|
|
</div>
|
|
)}
|
|
|
|
<CheckFailuresNotice failures={checkFailures} />
|
|
|
|
{loading && groups.length === 0 ? (
|
|
<div className="flex items-center justify-center py-16 font-mono text-xs text-stat-subtitle">
|
|
Loading readiness...
|
|
</div>
|
|
) : groups.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 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
|
|
<div className="font-display italic text-xl text-stat-value">
|
|
{cadence?.enabled === false
|
|
? 'Detection disabled'
|
|
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
|
|
</div>
|
|
<div className="font-mono text-[11px] text-stat-subtitle">
|
|
{cadence?.enabled === false
|
|
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
|
|
: checkFailures.length > 0
|
|
? 'Review the unresolved checks above, then recheck.'
|
|
: 'Sencho rechecks registries on the configured interval.'}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-8">
|
|
{groups.map(group => (
|
|
<NodeGroupSection
|
|
key={group.nodeId}
|
|
group={group}
|
|
canServiceUpdate={serviceScopedNodeIds.has(group.nodeId)}
|
|
onApply={handleApply}
|
|
onApplyService={handleApplyService}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AutoUpdateReadinessView(props: AutoUpdateReadinessProps = {}) {
|
|
return <AutoUpdateReadinessContent {...props} />;
|
|
}
|