mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore Allow updating or rebuilding one declared Compose service on multi-service stacks without recreating siblings, with recovery snapshots, health-gate observation, and prune holds for rollback images. Full-stack update paths and single-service UX stay unchanged. * fix: sanitize service-scoped update log messages for CodeQL * fix: address service-scoped update audit findings B-01 through B-07 * fix: complete service-scoped update audit metadata and surfaces * test: wrap Updates readiness tests for deploy-feedback context * fix: keep service recovery reachable without Deploy Progress Make failed service-gate recovery discoverable when Deploy Progress is disabled or dismissed, suppress stale image-scan notification side effects, normalize ComposeService line endings, and add focused regression coverage. * fix: resurface ContainersHealth density and expand on multi-service stacks Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
@@ -12,6 +12,9 @@ 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';
|
||||
|
||||
@@ -38,11 +41,22 @@ interface UpdatePreview {
|
||||
update_kind: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
has_build_services?: boolean;
|
||||
rebuild_available?: boolean;
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
export interface StackCard {
|
||||
stack: string;
|
||||
nodeId: number;
|
||||
@@ -55,6 +69,9 @@ export interface StackCard {
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface NodeGroup {
|
||||
@@ -186,17 +203,25 @@ function VersionDiff({ current, next }: { current: string | null; next: string |
|
||||
|
||||
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, autoUpdateEnabled } = card;
|
||||
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, 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 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;
|
||||
|
||||
return (
|
||||
@@ -267,6 +292,25 @@ function StackReadinessCard({
|
||||
</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}
|
||||
>
|
||||
{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 ? (
|
||||
@@ -285,7 +329,7 @@ function StackReadinessCard({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApply(stack, nodeId)}
|
||||
disabled={blocked || applying}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
title={blocked ? (blockedReason ?? undefined) : undefined}
|
||||
className="gap-1.5"
|
||||
>
|
||||
@@ -378,10 +422,14 @@ function ReadinessHero({
|
||||
|
||||
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;
|
||||
@@ -401,7 +449,13 @@ function NodeGroupSection({
|
||||
</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} />
|
||||
<StackReadinessCard
|
||||
key={`${card.nodeId}::${card.stack}`}
|
||||
card={card}
|
||||
canServiceUpdate={canServiceUpdate}
|
||||
onApply={onApply}
|
||||
onApplyService={onApplyService}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -412,11 +466,23 @@ function NodeGroupSection({
|
||||
|
||||
/** 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, onApply }: { card: StackCard; onApply: (stack: string, nodeId: number) => void }) {
|
||||
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = card;
|
||||
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 } = card;
|
||||
const failed = previewLoaded && preview === null;
|
||||
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 changelog = preview?.changelog ?? 'No changelog available from the registry yet.';
|
||||
const dot = changelog.indexOf('.');
|
||||
@@ -458,6 +524,24 @@ export function MobileReadinessCard({ card, onApply }: { card: StackCard; onAppl
|
||||
<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}
|
||||
>
|
||||
{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 ? 'text-destructive' : 'text-stat-subtitle'}`}>
|
||||
{nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)}</> : (blocked ? 'Held for review' : 'No schedule')}
|
||||
@@ -466,7 +550,7 @@ export function MobileReadinessCard({ card, onApply }: { card: StackCard; onAppl
|
||||
size="sm"
|
||||
variant={blocked ? 'outline' : 'default'}
|
||||
onClick={() => onApply(stack, nodeId)}
|
||||
disabled={blocked || applying}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
@@ -479,7 +563,17 @@ export function MobileReadinessCard({ card, onApply }: { card: StackCard; onAppl
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNodeSection({ group, onApply }: { group: NodeGroup; onApply: (stack: string, nodeId: number) => void }) {
|
||||
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">
|
||||
@@ -489,7 +583,13 @@ function MobileNodeSection({ group, onApply }: { group: NodeGroup; onApply: (sta
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{group.cards.map(card => (
|
||||
<MobileReadinessCard key={`${card.nodeId}::${card.stack}`} card={card} onApply={onApply} />
|
||||
<MobileReadinessCard
|
||||
key={`${card.nodeId}::${card.stack}`}
|
||||
card={card}
|
||||
canServiceUpdate={canServiceUpdate}
|
||||
onApply={onApply}
|
||||
onApplyService={onApplyService}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -527,7 +627,8 @@ interface AutoUpdateReadinessProps {
|
||||
|
||||
function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const { nodes } = useNodes();
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
const { nodes, nodeMeta, refreshNodeMeta } = useNodes();
|
||||
const [groups, setGroups] = useState<NodeGroup[]>([]);
|
||||
const [reachableNodeCount, setReachableNodeCount] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -650,6 +751,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
previewLoaded: false,
|
||||
scheduledTask,
|
||||
applying: false,
|
||||
applyingService: null,
|
||||
autoUpdateEnabled: scheduledTask !== null,
|
||||
};
|
||||
});
|
||||
@@ -667,6 +769,12 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
|
||||
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 }) => {
|
||||
@@ -700,7 +808,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
} finally {
|
||||
if (token === loadTokenRef.current) setLoading(false);
|
||||
}
|
||||
}, [localNodeId]);
|
||||
}, [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
|
||||
@@ -774,6 +882,66 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
}
|
||||
}, [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 res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
|
||||
if (res.ok) {
|
||||
const next = await res.json() as UpdatePreview;
|
||||
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
|
||||
}
|
||||
} catch {
|
||||
// Preview refresh is best-effort; the update itself already succeeded.
|
||||
}
|
||||
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 => ({
|
||||
@@ -861,7 +1029,15 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">Sencho rechecks registries on the configured interval.</div>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(group => <MobileNodeSection key={group.nodeId} group={group} onApply={handleApply} />)
|
||||
groups.map(group => (
|
||||
<MobileNodeSection
|
||||
key={group.nodeId}
|
||||
group={group}
|
||||
canServiceUpdate={serviceScopedNodeIds.has(group.nodeId)}
|
||||
onApply={handleApply}
|
||||
onApplyService={handleApplyService}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,7 +1079,13 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
{groups.map(group => (
|
||||
<NodeGroupSection key={group.nodeId} group={group} onApply={handleApply} />
|
||||
<NodeGroupSection
|
||||
key={group.nodeId}
|
||||
group={group}
|
||||
canServiceUpdate={serviceScopedNodeIds.has(group.nodeId)}
|
||||
onApply={handleApply}
|
||||
onApplyService={handleApplyService}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user