mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
1f8ce773ff
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists.
650 lines
24 KiB
TypeScript
650 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } 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 { 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;
|
|
}
|
|
|
|
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;
|
|
};
|
|
rollback_target: string | null;
|
|
changelog: string | null;
|
|
}
|
|
|
|
interface StackCard {
|
|
stack: string;
|
|
nodeId: number;
|
|
preview: UpdatePreview | null;
|
|
previewLoaded: boolean;
|
|
scheduledTask: ScheduledTask | null;
|
|
applying: boolean;
|
|
autoUpdateEnabled: boolean;
|
|
}
|
|
|
|
interface NodeGroup {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
nodeType: 'local' | 'remote';
|
|
cards: StackCard[];
|
|
}
|
|
|
|
interface FleetUpdateResponse {
|
|
[nodeId: string]: Record<string, 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] leading-3 uppercase tracking-[0.18em] 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] 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,
|
|
onApply,
|
|
}: {
|
|
card: StackCard;
|
|
onApply: (stack: string, nodeId: number) => void;
|
|
}) {
|
|
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = 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>
|
|
<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} />}
|
|
</div>
|
|
</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 (
|
|
<>
|
|
{p.summary.update_kind === 'digest' ? (
|
|
<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>
|
|
) : (
|
|
<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, nodeId)}
|
|
disabled={blocked || applying || !autoUpdateEnabled}
|
|
title={
|
|
!autoUpdateEnabled
|
|
? 'Auto-updates are disabled for this stack. Update it from its actions menu.'
|
|
: (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,
|
|
nodeCount,
|
|
refreshing,
|
|
onRefresh,
|
|
}: {
|
|
total: number;
|
|
ready: number;
|
|
nodeCount: number;
|
|
refreshing: boolean;
|
|
onRefresh: () => void;
|
|
}) {
|
|
const headline = total === 0
|
|
? '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-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{acrossNodes}
|
|
{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 NodeGroupSection({
|
|
group,
|
|
onApply,
|
|
}: {
|
|
group: NodeGroup;
|
|
onApply: (stack: string, nodeId: number) => 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-display italic 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} onApply={onApply} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AutoUpdateReadinessContent() {
|
|
const { nodes } = useNodes();
|
|
const [groups, setGroups] = useState<NodeGroup[]>([]);
|
|
const [reachableNodeCount, setReachableNodeCount] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Monotonic token guards against stale setGroups from older fetches.
|
|
const loadTokenRef = 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] = await Promise.all([
|
|
apiFetch('/image-updates/fleet', { localOnly: true }),
|
|
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
|
|
]);
|
|
// Auto-update settings are per-node; fetch lazily after we know which nodes have updates.
|
|
// Collected into a map keyed by nodeId once we know the fleet topology.
|
|
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);
|
|
|
|
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
|
|
const taskByNodeStack = new Map<string, ScheduledTask>();
|
|
for (const t of tasks) {
|
|
if (t.target_type !== 'stack' || !t.target_id) continue;
|
|
// Tasks with node_id=null are local-node-scoped.
|
|
const taskNodeId = t.node_id ?? localNodeId;
|
|
if (taskNodeId == null) continue;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Fetch auto-update settings for all nodes that have pending updates.
|
|
const nodeIdsWithUpdates = [...new Set(
|
|
Object.keys(fleetStatus).map(Number).filter(id => Object.values(fleetStatus[String(id)]).some(Boolean))
|
|
)];
|
|
const autoUpdateByNode = new Map<number, Record<string, boolean>>();
|
|
await Promise.all(nodeIdsWithUpdates.map(async (nodeId) => {
|
|
try {
|
|
const res = await fetchForNode('/stacks/auto-update-settings', nodeId);
|
|
if (res.ok) autoUpdateByNode.set(nodeId, await res.json() as Record<string, boolean>);
|
|
} catch {
|
|
// If the fetch fails, default all stacks on that node to enabled.
|
|
}
|
|
}));
|
|
|
|
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(([, hasUpdate]) => hasUpdate)
|
|
.map(([stack]) => stack)
|
|
.sort();
|
|
if (stacks.length === 0) continue;
|
|
const nodeAutoUpdateSettings = autoUpdateByNode.get(nodeId) ?? {};
|
|
const cards: StackCard[] = stacks.map(stack => {
|
|
flatPairs.push({ nodeId, stack });
|
|
return {
|
|
stack,
|
|
nodeId,
|
|
preview: null,
|
|
previewLoaded: false,
|
|
scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null,
|
|
applying: false,
|
|
autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true,
|
|
};
|
|
});
|
|
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);
|
|
|
|
const previews = await Promise.all(
|
|
flatPairs.map(async ({ nodeId, stack }) => {
|
|
try {
|
|
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
|
|
if (!res.ok) return null;
|
|
return await res.json() 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]);
|
|
});
|
|
|
|
setGroups(initialGroups.map(g => ({
|
|
...g,
|
|
cards: g.cards.map(c => ({
|
|
...c,
|
|
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
|
|
previewLoaded: true,
|
|
})),
|
|
})));
|
|
} 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]);
|
|
|
|
useEffect(() => {
|
|
if (nodesSignature === '') return;
|
|
loadReadiness();
|
|
return () => {
|
|
// Invalidate any in-flight fetch and cancel pending refresh timers on unmount.
|
|
loadTokenRef.current++;
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current);
|
|
refreshTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [loadReadiness, 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;
|
|
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]);
|
|
|
|
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),
|
|
})));
|
|
|
|
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: true });
|
|
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');
|
|
}
|
|
toast.success(`${stack} updated successfully`);
|
|
setGroups(prev => prev
|
|
.map(g => g.nodeId === nodeId
|
|
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
|
|
: g)
|
|
.filter(g => g.cards.length > 0));
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Update failed');
|
|
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: false });
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
}
|
|
}, []);
|
|
|
|
const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]);
|
|
const { total, ready } = useMemo(() => {
|
|
const t = flatCards.length;
|
|
const r = flatCards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length;
|
|
return { total: t, ready: r };
|
|
}, [flatCards]);
|
|
|
|
const showPartialBanner = reachableNodeCount != null
|
|
&& onlineNodeCount > 0
|
|
&& reachableNodeCount < onlineNodeCount;
|
|
|
|
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}
|
|
/>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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 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="flex flex-col gap-8">
|
|
{groups.map(group => (
|
|
<NodeGroupSection key={group.nodeId} group={group} onApply={handleApply} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AutoUpdateReadinessView() {
|
|
return (
|
|
<PaidGate>
|
|
<AutoUpdateReadinessContent />
|
|
</PaidGate>
|
|
);
|
|
}
|