mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow';
|
||||
import TerminalComponent from '@/components/Terminal';
|
||||
import { useDeployFeedback, VERB_LABELS, type HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
import { requestServiceRestore } from '@/lib/serviceUpdate';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
const AUTO_CLOSE_SECONDS = 4;
|
||||
|
||||
@@ -364,15 +366,62 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
// Re-renders every second via the elapsed-time interval, so the observing
|
||||
// elapsed count stays live without its own timer.
|
||||
function HealthGateBanner({ gate }: { gate: HealthGateUiState }) {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const elapsed = gate.startedAt ? Math.max(0, Math.floor((Date.now() - gate.startedAt) / 1000)) : 0;
|
||||
const windowLabel = gate.windowSeconds ? ` of ${gate.windowSeconds}s` : '';
|
||||
// Service-scoped gates name the service; a full-stack gate keeps the
|
||||
// existing "containers"/"the stack" phrasing unchanged.
|
||||
const scopeSubject = gate.serviceName ? `service "${gate.serviceName}"` : 'containers';
|
||||
const collateralNote = gate.failureSource === 'collateral' ? ' A dependent service triggered the failure.' : '';
|
||||
const canRestoreService =
|
||||
gate.targetScope === 'service'
|
||||
&& !!gate.serviceName
|
||||
&& !!gate.recoveryId
|
||||
&& gate.status === 'failed';
|
||||
|
||||
const onRestoreService = useCallback(async () => {
|
||||
if (!gate.serviceName || !gate.recoveryId || restoring) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
await runWithLog(
|
||||
{ stackName: gate.stackName, action: 'update', nodeId: gate.nodeId, serviceName: gate.serviceName },
|
||||
async (started, ds) => {
|
||||
await started;
|
||||
const result = await requestServiceRestore({
|
||||
nodeId: gate.nodeId,
|
||||
stackName: gate.stackName,
|
||||
serviceName: gate.serviceName as string,
|
||||
recoveryId: gate.recoveryId as string,
|
||||
deploySessionId: ds,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return { ok: false as const, errorMessage: result.error };
|
||||
}
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${gate.serviceName}" restored. Verifying health...`);
|
||||
} else {
|
||||
toast.success(`Service "${gate.serviceName}" restored successfully!`);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
healthGateId: result.observing ? result.healthGateId : null,
|
||||
recoveryId: result.recoveryId,
|
||||
};
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}, [gate, restoring, runWithLog]);
|
||||
|
||||
if (gate.status === 'observing') {
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="observing" className="flex items-start gap-2 px-4 py-2 border-b border-glass-border bg-card/40 shrink-0">
|
||||
<HeartPulse className="h-3.5 w-3.5 mt-0.5 shrink-0 text-brand" />
|
||||
<p className="min-w-0 text-xs text-muted-foreground">
|
||||
Health gate: observing containers ({elapsed}s{windowLabel}). Closing this panel does not stop the observation.
|
||||
Health gate: observing {scopeSubject} ({elapsed}s{windowLabel}). Closing this panel does not stop the observation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -382,7 +431,7 @@ function HealthGateBanner({ gate }: { gate: HealthGateUiState }) {
|
||||
<div data-testid="health-gate-banner" data-status="passed" className="flex items-start gap-2 px-4 py-2 border-b border-success/30 bg-success/5 shrink-0">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mt-0.5 shrink-0 text-success" />
|
||||
<p className="min-w-0 text-xs text-success">
|
||||
Health gate passed: containers stayed healthy through the observation window.
|
||||
Health gate passed: {scopeSubject} stayed healthy through the observation window.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -391,9 +440,26 @@ function HealthGateBanner({ gate }: { gate: HealthGateUiState }) {
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="failed" className="flex items-start gap-2 px-4 py-2 border-b border-destructive/30 bg-destructive/5 shrink-0">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0 text-destructive" />
|
||||
<p className="min-w-0 text-xs text-destructive">
|
||||
Health gate failed{gate.reason ? `: ${gate.reason}` : ''}. Rollback options are available on the stack.
|
||||
</p>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-xs text-destructive">
|
||||
Health gate failed{gate.reason ? `: ${gate.reason}` : ''}.{collateralNote}
|
||||
{canRestoreService
|
||||
? ` Restore "${gate.serviceName}" from the recovery snapshot, or inspect the stack.`
|
||||
: ' Rollback options are available on the stack.'}
|
||||
</p>
|
||||
{canRestoreService && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="service-restore-from-gate"
|
||||
disabled={restoring}
|
||||
onClick={() => { void onRestoreService(); }}
|
||||
>
|
||||
{restoring ? 'Restoring...' : `Restore ${gate.serviceName}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { useTopNavQuickLinks } from '@/hooks/use-top-nav-quick-links';
|
||||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||||
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { useServiceUpdateStatus } from '@/hooks/useServiceUpdateStatus';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
@@ -112,6 +113,8 @@ export default function EditorLayout() {
|
||||
backupInfo,
|
||||
isEditing,
|
||||
editingCompose, setEditingCompose,
|
||||
effectiveServices,
|
||||
serviceUpdateInProgress,
|
||||
} = editorState;
|
||||
|
||||
const stackListState = useStackListState();
|
||||
@@ -258,6 +261,8 @@ export default function EditorLayout() {
|
||||
activeNode?.id ?? null,
|
||||
);
|
||||
|
||||
const serviceUpdateStatuses = useServiceUpdateStatus(stackUpdates, selectedFile);
|
||||
|
||||
const stackActions = useStackActions({
|
||||
editorState,
|
||||
stackListState,
|
||||
@@ -271,6 +276,7 @@ export default function EditorLayout() {
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard: hasCapability('update-guard'),
|
||||
hasGuidedExternalNetworkPreflight: hasCapability('guided-external-network-preflight'),
|
||||
hasServiceScopedUpdate: hasCapability('service-scoped-update'),
|
||||
canEditStack: (stackNameOrFilename) => {
|
||||
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
|
||||
return can('stack:edit', 'stack', stackName);
|
||||
@@ -639,6 +645,13 @@ export default function EditorLayout() {
|
||||
openLogViewer={stackActions.openLogViewer}
|
||||
openBashModal={stackActions.openBashModal}
|
||||
serviceAction={stackActions.serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={(serviceName, mode) => {
|
||||
if (!selectedFile) return;
|
||||
void stackActions.requestServiceUpdate(selectedFile, serviceName, mode);
|
||||
}}
|
||||
setActiveTab={setActiveTab}
|
||||
setLogsMode={setLogsMode}
|
||||
setEditingCompose={setEditingCompose}
|
||||
|
||||
@@ -47,6 +47,8 @@ import type { NotificationItem } from '../dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
export interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -176,6 +178,14 @@ export interface EditorViewProps {
|
||||
action: 'start' | 'stop' | 'restart',
|
||||
serviceName: string,
|
||||
) => Promise<void>;
|
||||
// Declared-service facts for the multi-service header split (§12). Empty
|
||||
// on single-service stacks and older remotes (capability-gated fetch), so
|
||||
// ContainersHealth falls back to the flat single-service layout. Optional
|
||||
// so callers/tests that never deal in services can omit them.
|
||||
effectiveServices?: EffectiveServiceSpec[];
|
||||
serviceUpdateStatuses?: StackServiceUpdateStatus[];
|
||||
serviceUpdateInProgress?: { service: string; mode: 'update' | 'rebuild' } | null;
|
||||
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
||||
|
||||
// UI state setters
|
||||
setActiveTab: (tab: 'compose' | 'env' | 'files') => void;
|
||||
@@ -263,6 +273,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices = [],
|
||||
serviceUpdateStatuses = [],
|
||||
serviceUpdateInProgress = null,
|
||||
onRequestServiceUpdate,
|
||||
setActiveTab,
|
||||
setLogsMode,
|
||||
setEditingCompose,
|
||||
@@ -368,6 +382,11 @@ export function EditorView(props: EditorViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Declared-service headers (§12) need the same expandable, scroll-wrapped
|
||||
// layout as a multi-container stack even when only one container of a
|
||||
// multi-service stack is currently running.
|
||||
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
||||
|
||||
// Below md, render the segmented full-screen mobile detail instead of the
|
||||
// desktop two-pane grid. All hooks above run unconditionally before this
|
||||
// branch so hook order stays stable across breakpoints.
|
||||
@@ -386,7 +405,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Command Center Card (identity + health strip). Hidden when
|
||||
the logs are expanded so the logs pane fills the column. */}
|
||||
{!logsExpanded && (
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 && !containersExpanded ? 'flex flex-col min-h-0 max-h-[42%]' : safeContainers.length > 1 && containersExpanded ? 'flex flex-col flex-1 min-h-0' : 'shrink-0'}`}>
|
||||
<Card className={`rounded-xl border-muted bg-card ${isMultiContainerLayout && !containersExpanded ? 'flex flex-col min-h-0 max-h-[42%]' : isMultiContainerLayout && containersExpanded ? 'flex flex-col flex-1 min-h-0' : 'shrink-0'}`}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -438,7 +457,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
panelStartedAt={panelStartedAt}
|
||||
variant="band"
|
||||
/>
|
||||
{safeContainers.length > 1 ? (
|
||||
{isMultiContainerLayout ? (
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<ContainersHealth
|
||||
@@ -450,6 +469,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
containersExpanded={containersExpanded}
|
||||
onToggleContainersExpand={toggleContainersExpand}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
@@ -467,6 +490,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -477,7 +504,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Logs Section (fills remaining left-column height). On multi-
|
||||
container stacks a min-h guarantees logs are never hidden.
|
||||
Hidden when containers are expanded to fill the column. */}
|
||||
{!containersExpanded && (safeContainers.length > 1 ? (
|
||||
{!containersExpanded && (isMultiContainerLayout ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
|
||||
@@ -62,6 +62,10 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices,
|
||||
serviceUpdateStatuses,
|
||||
serviceUpdateInProgress,
|
||||
onRequestServiceUpdate,
|
||||
setLogsMode,
|
||||
setActiveTab,
|
||||
setGitSourceOpen,
|
||||
@@ -225,6 +229,10 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,8 @@ export function ShellOverlays({
|
||||
open={updateReadiness !== null}
|
||||
stackName={updateReadiness?.stackName ?? ''}
|
||||
nodeId={updateReadiness?.nodeId ?? null}
|
||||
serviceName={updateReadiness?.serviceName}
|
||||
mode={updateReadiness?.mode}
|
||||
onCancel={() => setUpdateReadiness(null)}
|
||||
onProceed={() => updateReadiness?.proceed()}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../Terminal', () => ({ default: () => null }));
|
||||
@@ -10,6 +11,8 @@ import { ContainersHealth } from '../editor-view-blocks';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
const LOCAL_NODE = { id: 1, type: 'local' } as Node;
|
||||
|
||||
@@ -221,3 +224,229 @@ describe('density toggle and summary strip', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('declared-service headers (multi-service only)', () => {
|
||||
function makeContainer(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
|
||||
return {
|
||||
Id: overrides.Id || 'abc',
|
||||
Names: overrides.Names || ['/app'],
|
||||
State: overrides.State || 'running',
|
||||
Status: overrides.Status || 'Up 1 hour',
|
||||
Image: overrides.Image || 'nginx',
|
||||
...overrides,
|
||||
} as unknown as ContainerInfo;
|
||||
}
|
||||
|
||||
function spec(overrides: Partial<EffectiveServiceSpec> = {}): EffectiveServiceSpec {
|
||||
return {
|
||||
name: 'web',
|
||||
declaredImage: 'nginx:latest',
|
||||
hasBuild: false,
|
||||
expectedReplicas: 1,
|
||||
dependsOn: [],
|
||||
hasHealthcheck: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function status(overrides: Partial<StackServiceUpdateStatus> = {}): StackServiceUpdateStatus {
|
||||
return {
|
||||
service: 'web',
|
||||
image: 'nginx:latest',
|
||||
hasUpdate: false,
|
||||
checkStatus: 'ok',
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders no declared-service header for a single effective service (unchanged single-service UX)', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Service: 'web' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec()]}
|
||||
/>,
|
||||
);
|
||||
// No grouped "X/Y running" service header; the flat per-container card
|
||||
// layout (with its own pre-existing "Service actions" menu) is unchanged.
|
||||
expect(screen.queryByText(/running$/)).toBeNull();
|
||||
expect(screen.getByLabelText('Service actions')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View logs' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one header per declared service and groups containers under it', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('db')).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
// Per-container service menu is hidden inside a multi-service header group.
|
||||
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the Update badge only for the service with a confirmed update', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
// db is not update-eligible (no image, no build) so it renders no
|
||||
// Update button/badge at all, keeping the "web" button unambiguous.
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: null })]}
|
||||
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Update', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses Rebuild wording with no badge for a build-backed service without a detected update', () => {
|
||||
const onRequestServiceUpdate = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web', hasBuild: true, declaredImage: null }), spec({ name: 'db' })]}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Update', { selector: 'span' })).toBeNull();
|
||||
const rebuildBtn = screen.getByRole('button', { name: /^Rebuild$/ });
|
||||
expect(rebuildBtn).toBeInTheDocument();
|
||||
fireEvent.click(rebuildBtn);
|
||||
expect(onRequestServiceUpdate).toHaveBeenCalledWith('web', 'rebuild');
|
||||
});
|
||||
|
||||
it('moves Start/Stop/Restart to the declared-service header menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
const serviceAction = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByLabelText('Service actions')[0]);
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Restart service' }));
|
||||
expect(serviceAction).toHaveBeenCalledWith('restart', 'web');
|
||||
});
|
||||
|
||||
it('still surfaces summary strip and density toggle on multi-service stacks', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
makeContainer({ Id: 'd2', Service: 'db', State: 'paused' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/3 containers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 up/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 paused/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Compact view' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles detailed sparklines on the multi-service path', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detailed view' }));
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('surfaces expand control on multi-service stacks when wired', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
containersExpanded={false}
|
||||
onToggleContainersExpand={onToggle}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand containers' }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ import StructuredLogViewer from '../StructuredLogViewer';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
const extractUptime = (status: string | undefined): string | null => {
|
||||
if (!status) return null;
|
||||
@@ -304,6 +306,15 @@ export interface ContainersHealthProps {
|
||||
openLogViewer: (containerId: string, containerName: string) => void;
|
||||
openBashModal: (containerId: string, containerName: string) => void;
|
||||
serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise<void>;
|
||||
// Declared Compose services from the effective model. Multi-service
|
||||
// headers (owning Update/Rebuild + badge + Start/Stop/Restart) render only
|
||||
// when this has more than one entry; empty/single leaves the flat
|
||||
// container-card layout below untouched. Optional so callers that never
|
||||
// deal in services (and existing tests) can omit them.
|
||||
effectiveServices?: EffectiveServiceSpec[];
|
||||
serviceUpdateStatuses?: StackServiceUpdateStatus[];
|
||||
serviceUpdateInProgress?: { service: string; mode: 'update' | 'rebuild' } | null;
|
||||
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
||||
containersExpanded?: boolean;
|
||||
onToggleContainersExpand?: () => void;
|
||||
}
|
||||
@@ -319,16 +330,23 @@ export function ContainersHealth({
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices = [],
|
||||
serviceUpdateStatuses = [],
|
||||
serviceUpdateInProgress = null,
|
||||
onRequestServiceUpdate,
|
||||
containersExpanded,
|
||||
onToggleContainersExpand,
|
||||
}: ContainersHealthProps) {
|
||||
// Multi-service only (§12): a single-service stack keeps the existing flat
|
||||
// layout untouched, including its per-container Start/Stop/Restart kebab.
|
||||
const isMultiService = effectiveServices.length > 1;
|
||||
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
|
||||
const copiedUrlTimerRef = useRef<number | null>(null);
|
||||
// Compact mode hides sparkline grids across all containers for a denser
|
||||
// list. Detailed mode (default) shows CPU / Mem / Net per container.
|
||||
const [density, setDensity] = useState<'compact' | 'detailed'>(
|
||||
safeContainers.length > 1 ? 'compact' : 'detailed',
|
||||
);
|
||||
safeContainers.length > 1 ? 'compact' : 'detailed',
|
||||
);
|
||||
useEffect(() => () => {
|
||||
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
|
||||
}, []);
|
||||
@@ -343,102 +361,85 @@ export function ContainersHealth({
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
|
||||
// Summary strip + density/expand toggles: multi-container stacks only,
|
||||
// whether the body is flat or grouped by declared service.
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
const densityToolbar = total > 1 ? (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
|
||||
Stats unavailable
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containerStatsError}</TooltipContent>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{onToggleContainersExpand && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContainersExpand}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={containersExpanded}
|
||||
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
|
||||
>
|
||||
{containersExpanded
|
||||
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary strip + density toggle appear only for multi-container
|
||||
stacks; single-container stacks keep the original layout. */}
|
||||
{safeContainers.length > 1 && (() => {
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{onToggleContainersExpand && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContainersExpand}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={containersExpanded}
|
||||
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
|
||||
>
|
||||
{containersExpanded
|
||||
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// One container card. `hideServiceMenu` drops the per-container
|
||||
// Start/Stop/Restart kebab on multi-service stacks, where the declared-
|
||||
// service header above owns that action instead (§12 point 4: child cards
|
||||
// keep only logs, shell, ports, metrics).
|
||||
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
@@ -574,7 +575,7 @@ export function ContainersHealth({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{container.Service && (
|
||||
{!hideServiceMenu && container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -639,9 +640,126 @@ export function ContainersHealth({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
|
||||
Stats unavailable
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containerStatsError}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{densityToolbar}
|
||||
{isMultiService ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{effectiveServices.map(spec => {
|
||||
const group = safeContainers.filter(c => c.Service === spec.name);
|
||||
const status = serviceUpdateStatuses.find(s => s.service === spec.name);
|
||||
const busy = serviceUpdateInProgress?.service === spec.name;
|
||||
const hasUpdate = status?.hasUpdate === true;
|
||||
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
|
||||
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
|
||||
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
|
||||
const runningCount = group.filter(c => c.State === 'running').length;
|
||||
const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas';
|
||||
const replicaCopy = mode === 'rebuild'
|
||||
? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}`
|
||||
: `Updates all ${spec.expectedReplicas} ${replicaWord}`;
|
||||
return (
|
||||
<div key={spec.name} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">{spec.name}</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
{runningCount}/{spec.expectedReplicas} running
|
||||
</span>
|
||||
{hasUpdate && (
|
||||
<span className="rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showUpdateAction && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md px-2 max-md:h-11"
|
||||
onClick={() => onRequestServiceUpdate?.(spec.name, mode)}
|
||||
disabled={busy}
|
||||
>
|
||||
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
{busy
|
||||
? (mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
|
||||
: (mode === 'rebuild' ? 'Rebuild' : 'Update')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{replicaCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isServiceActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', spec.name)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', spec.name)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', spec.name)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{group.length > 0 ? (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
|
||||
{group.map(container => renderContainerCard(container, true))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-2 pl-3 font-mono text-xs text-muted-foreground">
|
||||
No containers running for this service.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => renderContainerCard(container, false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import { classifyFailedGate } from './failed-gate-recovery';
|
||||
import type { HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
type Gate = Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName'>;
|
||||
const gate = (over: Partial<Gate> = {}): Gate => ({ status: 'failed', nodeId: null, stackName: 'web', ...over });
|
||||
type Gate = Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName' | 'targetScope'>;
|
||||
const gate = (over: Partial<Gate> = {}): Gate => ({ status: 'failed', nodeId: null, stackName: 'web', targetScope: 'stack', ...over });
|
||||
|
||||
describe('classifyFailedGate', () => {
|
||||
it('skips when there is no gate', () => {
|
||||
@@ -43,4 +43,8 @@ describe('classifyFailedGate', () => {
|
||||
it('reports no-file when the node and file list match but no stack file matches the name yet', () => {
|
||||
expect(classifyFailedGate(gate({ nodeId: 3, stackName: 'web' }), 3, 3, ['other.yml'])).toEqual({ kind: 'no-file' });
|
||||
});
|
||||
|
||||
it('skips a failed service-scoped gate: the stack rollback recovery does not apply to a single service', () => {
|
||||
expect(classifyFailedGate(gate({ targetScope: 'service' }), null, null, ['web.yml'])).toEqual({ kind: 'skip' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ import type { HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
* file matches its name yet (the list may be mid-refresh). The caller leaves it
|
||||
* unhandled so the effect retries once the files land.
|
||||
* - `record`: record a recovery entry against `stackFile`.
|
||||
*
|
||||
* A service-scoped gate (`targetScope === 'service'`) always classifies as
|
||||
* `skip`: the stack-level rollback recovery this feeds (RecoveryChip/Panel,
|
||||
* keyed off the stack's own `rollback_target`) does not know how to restore a
|
||||
* single service, so routing a service gate's failure into it would offer a
|
||||
* rollback action that does not correspond to what actually happened.
|
||||
*/
|
||||
export type FailedGateOutcome =
|
||||
| { kind: 'skip' }
|
||||
@@ -24,12 +30,13 @@ export type FailedGateOutcome =
|
||||
| { kind: 'record'; stackFile: string };
|
||||
|
||||
export function classifyFailedGate(
|
||||
healthGate: Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName'> | null,
|
||||
healthGate: Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName' | 'targetScope'> | null,
|
||||
activeNodeId: number | null,
|
||||
filesNodeId: number | null,
|
||||
files: string[],
|
||||
): FailedGateOutcome {
|
||||
if (!healthGate || healthGate.status !== 'failed') return { kind: 'skip' };
|
||||
if (healthGate.targetScope === 'service') return { kind: 'skip' };
|
||||
// Record only while on the gate's node AND with that node's file list loaded.
|
||||
if (healthGate.nodeId !== activeNodeId || healthGate.nodeId !== filesNodeId) return { kind: 'skip' };
|
||||
const stackFile = files.find(f => f.replace(/\.(yml|yaml)$/, '') === healthGate.stackName);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
|
||||
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
|
||||
|
||||
@@ -30,6 +31,16 @@ export function useEditorViewState() {
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
// Declared-service facts for the loaded stack, from the effective Compose
|
||||
// model. Empty for a single-service stack, an older node without the
|
||||
// service-scoped-update capability, or a render failure; all three cases
|
||||
// fail closed to the legacy per-container layout (no declared-service
|
||||
// headers), so this array doubles as the multi-service gate.
|
||||
const [effectiveServices, setEffectiveServices] = useState<EffectiveServiceSpec[]>([]);
|
||||
// The declared service currently running a manual update/rebuild, so the
|
||||
// owning header can show a busy state. Only one at a time, mirroring the
|
||||
// single `loadingAction` for stack-level operations.
|
||||
const [serviceUpdateInProgress, setServiceUpdateInProgress] = useState<{ service: string; mode: 'update' | 'rebuild' } | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('compose');
|
||||
const [logsMode, setLogsMode] = useState<LogsMode>(readLogsMode);
|
||||
@@ -56,6 +67,8 @@ export function useEditorViewState() {
|
||||
envFiles, setEnvFiles,
|
||||
selectedEnvFile, setSelectedEnvFile,
|
||||
containers, setContainers,
|
||||
effectiveServices, setEffectiveServices,
|
||||
serviceUpdateInProgress, setServiceUpdateInProgress,
|
||||
activeTab, setActiveTab,
|
||||
logsMode, setLogsMode,
|
||||
gitSourceOpen, setGitSourceOpen,
|
||||
|
||||
@@ -122,13 +122,18 @@ export function useOverlayState() {
|
||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
||||
|
||||
// Pre-update readiness dialog. `proceed` runs the actual update when the
|
||||
// user confirms; opened by useStackActions.requestStackUpdate. `nodeId` is
|
||||
// captured at open time so both the readiness fetch and the update run against
|
||||
// the same node even if the active node changes while the dialog is open.
|
||||
// user confirms; opened by useStackActions.requestStackUpdate (full stack)
|
||||
// or requestServiceUpdate (a single declared service). `nodeId` is captured
|
||||
// at open time so both the readiness fetch and the update run against the
|
||||
// same node even if the active node changes while the dialog is open.
|
||||
// `serviceName`/`mode` are set only for a service-scoped update; absent
|
||||
// means the full-stack readiness check, unchanged from before.
|
||||
const [updateReadiness, setUpdateReadiness] = useState<{
|
||||
stackName: string;
|
||||
stackFile: string;
|
||||
nodeId: number | null;
|
||||
serviceName?: string;
|
||||
mode?: 'update' | 'rebuild';
|
||||
proceed: () => void;
|
||||
} | null>(null);
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
|
||||
setGitSourcePendingMap: vi.fn(),
|
||||
setComposeEtag: vi.fn(),
|
||||
setEnvEtag: vi.fn(),
|
||||
effectiveServices: [],
|
||||
setEffectiveServices: vi.fn(),
|
||||
serviceUpdateInProgress: null,
|
||||
setServiceUpdateInProgress: vi.fn(),
|
||||
};
|
||||
return { ...base, ...over } as unknown as EditorState;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/lib/hydrationTiming';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
|
||||
import { requestServiceUpdate as postServiceUpdate, requestServiceRestore as postServiceRestore } from '@/lib/serviceUpdate';
|
||||
import type { EffectiveServiceModelResult } from '@/types/effectiveServices';
|
||||
import type { useEditorViewState } from './useEditorViewState';
|
||||
import type { useStackListState } from './useStackListState';
|
||||
import type { useViewNavigationState } from './useViewNavigationState';
|
||||
@@ -153,6 +155,11 @@ interface UseStackActionsOptions {
|
||||
// Active node advertises guided external-network preflight. Absent capability
|
||||
// keeps legacy deploy (no GET). Advertised-but-broken fails closed.
|
||||
hasGuidedExternalNetworkPreflight?: boolean;
|
||||
// Active node advertises service-scoped updates. Gates both the
|
||||
// effective-services fetch (skipped entirely on an older node, so
|
||||
// effectiveServices stays empty and no declared-service headers render)
|
||||
// and the manual per-service update/rebuild action.
|
||||
hasServiceScopedUpdate?: boolean;
|
||||
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
|
||||
// or compose path); callers strip extensions when comparing to RBAC stack
|
||||
// names. Evaluated against the load target so post-load auto-edit is not
|
||||
@@ -379,6 +386,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard = false,
|
||||
hasGuidedExternalNetworkPreflight = false,
|
||||
hasServiceScopedUpdate = false,
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
} = options;
|
||||
@@ -493,6 +501,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setSelectedEnvFile('');
|
||||
editorState.setEnvExists(false);
|
||||
editorState.setContainers([]);
|
||||
editorState.setEffectiveServices([]);
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
editorState.setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -692,6 +702,32 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
// Declared-service facts for the multi-service headers. Skipped entirely
|
||||
// without the capability so an older remote node never sees the extra
|
||||
// request; a render failure or non-ok response also fails closed to an
|
||||
// empty list, which keeps the legacy single-service layout.
|
||||
const loadEffectiveServicesState = async (filename: string, signal?: AbortSignal) => {
|
||||
if (!hasServiceScopedUpdate) {
|
||||
editorState.setEffectiveServices([]);
|
||||
return;
|
||||
}
|
||||
const stackName = filename.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/effective-services`, { signal });
|
||||
if (signal?.aborted) return;
|
||||
if (!res.ok) {
|
||||
editorState.setEffectiveServices([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as EffectiveServiceModelResult;
|
||||
if (signal?.aborted) return;
|
||||
editorState.setEffectiveServices(data.renderable ? data.services : []);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return;
|
||||
editorState.setEffectiveServices([]);
|
||||
}
|
||||
};
|
||||
|
||||
const applyEditorRouteState = (tab: EditorTab) => {
|
||||
editorState.setActiveTab(tab);
|
||||
editorState.setEditingCompose(true);
|
||||
@@ -764,6 +800,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
};
|
||||
}
|
||||
await loadBackupState(filename, signal);
|
||||
await loadEffectiveServicesState(filename, signal);
|
||||
// Post-load auto-edit evaluates permission for the loaded target, not
|
||||
// the previously selected stack (selectedFile was just updated above).
|
||||
if (options?.startInComposeEdit && canEditStack(filename)) {
|
||||
@@ -789,6 +826,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setOriginalEnvContent('');
|
||||
editorState.setEnvEtag(null);
|
||||
editorState.setContainers([]);
|
||||
editorState.setEffectiveServices([]);
|
||||
return { ok: false };
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
@@ -1586,6 +1624,115 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
await run();
|
||||
};
|
||||
|
||||
// Single entry point for a manual service-scoped update/rebuild (declared-
|
||||
// service header, Updates view per-service Apply). Uses the same deploy-
|
||||
// feedback session as full-stack Update so progress streams and the health
|
||||
// gate is polled; siblings are not intentionally recreated.
|
||||
const requestServiceUpdate = async (
|
||||
stackFile: string,
|
||||
serviceName: string,
|
||||
mode: 'update' | 'rebuild' = 'update',
|
||||
): Promise<void> => {
|
||||
if (stackListState.isStackBusy(stackFile) || editorState.serviceUpdateInProgress) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const opNodeId = activeNode?.id ?? null;
|
||||
const run = async () => {
|
||||
editorState.setServiceUpdateInProgress({ service: serviceName, mode });
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'update', nodeId: opNodeId, serviceName }, async (started, ds) => {
|
||||
await started;
|
||||
const result = await postServiceUpdate({
|
||||
nodeId: opNodeId,
|
||||
stackName,
|
||||
serviceName,
|
||||
mode,
|
||||
deploySessionId: ds,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return { ok: false as const, errorMessage: result.error };
|
||||
}
|
||||
const verb = mode === 'rebuild' ? 'rebuilt' : 'updated';
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${serviceName}" ${verb}. Verifying health...`);
|
||||
} else {
|
||||
toast.success(`Service "${serviceName}" ${verb} successfully!`);
|
||||
}
|
||||
if (result.recheckWarning) toast.info(result.recheckWarning);
|
||||
stackListState.fetchImageUpdates();
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return {
|
||||
ok: true as const,
|
||||
healthGateId: result.observing ? result.healthGateId : null,
|
||||
recoveryId: result.recoveryId,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
stackListState.refreshStacks(true);
|
||||
}
|
||||
};
|
||||
if (hasUpdateGuard) {
|
||||
overlayState.setUpdateReadiness({
|
||||
stackName,
|
||||
stackFile,
|
||||
nodeId: opNodeId,
|
||||
serviceName,
|
||||
mode,
|
||||
proceed: () => {
|
||||
overlayState.setUpdateReadiness(null);
|
||||
void run();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await run();
|
||||
};
|
||||
|
||||
const requestServiceRestore = async (
|
||||
stackFile: string,
|
||||
serviceName: string,
|
||||
recoveryId: string,
|
||||
): Promise<void> => {
|
||||
if (stackListState.isStackBusy(stackFile) || editorState.serviceUpdateInProgress) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const opNodeId = activeNode?.id ?? null;
|
||||
editorState.setServiceUpdateInProgress({ service: serviceName, mode: 'update' });
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'update', nodeId: opNodeId, serviceName }, async (started, ds) => {
|
||||
await started;
|
||||
const result = await postServiceRestore({
|
||||
nodeId: opNodeId,
|
||||
stackName,
|
||||
serviceName,
|
||||
recoveryId,
|
||||
deploySessionId: ds,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return { ok: false as const, errorMessage: result.error };
|
||||
}
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${serviceName}" restored. Verifying health...`);
|
||||
} else {
|
||||
toast.success(`Service "${serviceName}" restored successfully!`);
|
||||
}
|
||||
stackListState.fetchImageUpdates();
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return {
|
||||
ok: true as const,
|
||||
healthGateId: result.observing ? result.healthGateId : null,
|
||||
recoveryId: result.recoveryId,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
stackListState.refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStack = async (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
@@ -1969,6 +2116,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
serviceAction,
|
||||
updateStack,
|
||||
requestStackUpdate,
|
||||
requestServiceUpdate,
|
||||
requestServiceRestore,
|
||||
deleteStack,
|
||||
attemptLeaveEditor,
|
||||
attemptPopstateNavigation,
|
||||
|
||||
@@ -8,15 +8,34 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/lib/serviceUpdate', () => ({
|
||||
requestServiceUpdate: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('@/context/DeployFeedbackContext', () => ({
|
||||
useDeployFeedback: () => ({
|
||||
runWithLog: async (_params: unknown, fn: (started: Promise<void>, ds: string) => Promise<unknown>) =>
|
||||
fn(Promise.resolve(), 'test-session'),
|
||||
}),
|
||||
}));
|
||||
// nodeMeta/refreshNodeMeta must be stable across renders (matching the real
|
||||
// NodeContext), or a fresh Map/fn on every useNodes() call churns the
|
||||
// loadReadiness useCallback identity and re-triggers its effect forever.
|
||||
const mockNodeMeta = new Map();
|
||||
const mockRefreshNodeMeta = vi.fn();
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }),
|
||||
useNodes: () => ({
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }],
|
||||
nodeMeta: mockNodeMeta,
|
||||
refreshNodeMeta: mockRefreshNodeMeta,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { requestServiceUpdate } from '@/lib/serviceUpdate';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
@@ -25,6 +44,7 @@ function card(over: Partial<StackCard> = {}): StackCard {
|
||||
nodeId: 1,
|
||||
previewLoaded: true,
|
||||
applying: false,
|
||||
applyingService: null,
|
||||
autoUpdateEnabled: true,
|
||||
scheduledTask: null,
|
||||
preview: {
|
||||
@@ -83,6 +103,48 @@ it('enables Apply when no schedule covers the stack', () => {
|
||||
expect(apply()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('offers per-service Apply when build-only companions make the stack multi-service', () => {
|
||||
const onApplyService = vi.fn();
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
canServiceUpdate
|
||||
onApply={vi.fn()}
|
||||
onApplyService={onApplyService}
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'nextcloud',
|
||||
images: [{
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
}],
|
||||
build_services: ['cron'],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: true,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const serviceApply = screen.getByRole('button', { name: /^Apply$/i });
|
||||
expect(serviceApply).toBeEnabled();
|
||||
fireEvent.click(serviceApply);
|
||||
expect(onApplyService).toHaveBeenCalledWith('nextcloud', 1, 'app');
|
||||
});
|
||||
|
||||
/**
|
||||
* The desktop StackReadinessCard is not exported, so its Apply-now gating is
|
||||
* covered through a full-view render (useIsMobile is mocked false). A safe
|
||||
@@ -97,6 +159,8 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
afterEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
mockNodeMeta.clear();
|
||||
vi.mocked(requestServiceUpdate).mockReset();
|
||||
});
|
||||
|
||||
it('enables Apply for a safe update with no covering schedule', async () => {
|
||||
@@ -122,6 +186,97 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
// apply automatically": that still requires a covering schedule.
|
||||
expect(screen.getByText(/0 of 1 ready to apply automatically/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies a single service and refreshes the authoritative update preview', async () => {
|
||||
mockNodeMeta.set(1, {
|
||||
version: '1.0.0',
|
||||
capabilities: ['service-scoped-update'],
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
const multiPreview = {
|
||||
stack_name: 'nextcloud',
|
||||
images: [
|
||||
{
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
has_update: true,
|
||||
semver_bump: 'patch' as const,
|
||||
},
|
||||
{
|
||||
service: 'redis',
|
||||
image: 'redis:7',
|
||||
current_tag: '7.2',
|
||||
next_tag: '7.2',
|
||||
has_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch' as const,
|
||||
update_kind: 'tag' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
};
|
||||
const refreshedPreview = {
|
||||
...multiPreview,
|
||||
images: multiPreview.images.map((img) => (
|
||||
img.service === 'app' ? { ...img, has_update: false, current_tag: '27.1.5', next_tag: '27.1.5' } : img
|
||||
)),
|
||||
summary: { ...multiPreview.summary, has_update: false, current_tag: '27.1.5' },
|
||||
};
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { nextcloud: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
const call = mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => (call <= 1 ? multiPreview : refreshedPreview),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
vi.mocked(requestServiceUpdate).mockResolvedValue({
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: 'app',
|
||||
healthGateId: null,
|
||||
observing: false,
|
||||
recoveryId: null,
|
||||
recoveryAvailable: false,
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
const serviceApply = await screen.findByRole('button', { name: /^Apply$/i });
|
||||
await act(async () => { fireEvent.click(serviceApply); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestServiceUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stackName: 'nextcloud',
|
||||
serviceName: 'app',
|
||||
mode: 'update',
|
||||
}));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,15 @@ import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedb
|
||||
import { DeployFeedbackModal } from '../DeployFeedbackModal';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/serviceUpdate', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/serviceUpdate')>();
|
||||
return {
|
||||
...actual,
|
||||
requestServiceRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { requestServiceRestore } from '@/lib/serviceUpdate';
|
||||
|
||||
// Lets a test simulate a mid-stream drop (onReady then onError) so the panel
|
||||
// reaches 'streaming' with progressUnavailable set.
|
||||
@@ -15,12 +23,21 @@ const ctl = vi.hoisted(() => ({ drop: false, lastNodeId: undefined as number | n
|
||||
// the stream connected on mount so the panel reaches the 'streaming' state, and
|
||||
// records the captured nodeId it was mounted with.
|
||||
vi.mock('@/components/Terminal', () => {
|
||||
const MockTerminal = ({ onReady, onError, nodeId }: { onReady?: () => void; onError?: () => void; nodeId?: number | null }) => {
|
||||
const MockTerminal = ({
|
||||
onReady, onError, nodeId, deploySessionId,
|
||||
}: {
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
nodeId?: number | null;
|
||||
deploySessionId?: string | null;
|
||||
}) => {
|
||||
ctl.lastNodeId = nodeId;
|
||||
// Re-fire on each deploy session so a Restore (second runWithLog) can
|
||||
// release its progress-stream gate the same way the first update does.
|
||||
React.useEffect(() => {
|
||||
onReady?.();
|
||||
if (ctl.drop) onError?.();
|
||||
}, [onReady, onError]);
|
||||
}, [onReady, onError, deploySessionId]);
|
||||
return null;
|
||||
};
|
||||
return { default: MockTerminal };
|
||||
@@ -28,19 +45,28 @@ vi.mock('@/components/Terminal', () => {
|
||||
|
||||
// Resolver for the in-flight operation, assigned inside the run callback (async,
|
||||
// after render) so the test can leave it pending or settle it on demand.
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string; healthGateId?: string | null }) => void) | null = null;
|
||||
let resolveRun: ((r: {
|
||||
ok: boolean;
|
||||
errorMessage?: string;
|
||||
healthGateId?: string | null;
|
||||
recoveryId?: string | null;
|
||||
}) => void) | null = null;
|
||||
// The runWithLog promise itself, so a test can await full result propagation.
|
||||
let runOuter: Promise<unknown> | null = null;
|
||||
// Node the driver captures for the operation; default local, overridden per test.
|
||||
let driverNodeId: number | null = null;
|
||||
let driverServiceName: string | undefined;
|
||||
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: driverNodeId }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
|
||||
});
|
||||
runOuter = runWithLog(
|
||||
{ stackName: 'web', action: 'update', nodeId: driverNodeId, serviceName: driverServiceName },
|
||||
async (started) => {
|
||||
await started;
|
||||
return new Promise((res) => { resolveRun = res; });
|
||||
},
|
||||
);
|
||||
}, [runWithLog]);
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +86,14 @@ async function renderStreaming() {
|
||||
|
||||
type GateStatus = 'observing' | 'passed' | 'failed' | 'unknown';
|
||||
|
||||
function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?: string | null }>) {
|
||||
function routeGateApi(responses: Array<{
|
||||
id: string;
|
||||
status: GateStatus;
|
||||
reason?: string | null;
|
||||
serviceName?: string | null;
|
||||
targetScope?: 'stack' | 'service';
|
||||
failureSource?: 'primary' | 'collateral' | null;
|
||||
}>) {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (!String(url).includes('/health-gate')) {
|
||||
@@ -71,6 +104,7 @@ function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
stack: 'web', id: r.id, status: r.status, trigger: 'update',
|
||||
reason: r.reason ?? null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [],
|
||||
targetScope: r.targetScope ?? 'stack', serviceName: r.serviceName ?? null, failureSource: r.failureSource ?? null,
|
||||
}), { status: 200 }));
|
||||
});
|
||||
}
|
||||
@@ -83,8 +117,10 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
ctl.drop = false;
|
||||
ctl.lastNodeId = undefined;
|
||||
driverNodeId = null;
|
||||
driverServiceName = undefined;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.mocked(requestServiceRestore).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -152,6 +188,55 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the service in the banner for a service-scoped gate and notes a collateral failure', async () => {
|
||||
routeGateApi([{
|
||||
id: 'gate-1', status: 'failed', reason: 'service web has no running replicas to observe',
|
||||
serviceName: 'web', targetScope: 'service', failureSource: 'collateral',
|
||||
}]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'failed');
|
||||
expect(screen.getByText(/A dependent service triggered the failure/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Restore for a failed service gate and calls requestServiceRestore with the recovery id', async () => {
|
||||
driverServiceName = 'api';
|
||||
vi.mocked(requestServiceRestore).mockResolvedValue({
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: 'api',
|
||||
healthGateId: null,
|
||||
observing: false,
|
||||
recoveryId: 'rec-1',
|
||||
recoveryAvailable: false,
|
||||
});
|
||||
routeGateApi([{
|
||||
id: 'gate-1', status: 'failed', reason: 'service api reported unhealthy',
|
||||
serviceName: 'api', targetScope: 'service', failureSource: 'primary',
|
||||
}]);
|
||||
await renderStreaming();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60); });
|
||||
await act(async () => {
|
||||
resolveRun?.({ ok: true, healthGateId: 'gate-1', recoveryId: 'rec-1' });
|
||||
await runOuter;
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
const restoreBtn = screen.getByTestId('service-restore-from-gate');
|
||||
expect(restoreBtn).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
restoreBtn.click();
|
||||
});
|
||||
// Restore starts a fresh runWithLog; Terminal remounts for the new
|
||||
// deploySessionId and releases the gate after the 50ms handshake.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60); });
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(requestServiceRestore).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
recoveryId: 'rec-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('gives up with an unknown verdict after repeated poll failures', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (String(url).includes('/health-gate')) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
|
||||
interface StackHealthTableProps {
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
@@ -125,6 +126,9 @@ export function StackHealthTable({
|
||||
source: entry.source ?? 'local',
|
||||
mainPort: entry.mainPort ?? null,
|
||||
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
|
||||
outdatedServices: (stackUpdates[file]?.services ?? [])
|
||||
.filter((s) => s.hasUpdate)
|
||||
.map((s) => s.service),
|
||||
};
|
||||
});
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]);
|
||||
@@ -245,8 +249,11 @@ export function StackHealthTable({
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
{row.hasUpdate && (
|
||||
<span className="shrink-0 rounded-full bg-brand/15 px-2 py-0.5 font-mono text-[10px] leading-none text-brand tracking-wide">
|
||||
Update available
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-brand/15 px-2 py-0.5 font-mono text-[10px] leading-none text-brand tracking-wide"
|
||||
title={updateAvailableLabel(row.outdatedServices)}
|
||||
>
|
||||
{updateAvailableBadge(row.outdatedServices)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -253,6 +253,9 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
isActive={selectedFile === file}
|
||||
labels={stackLabelMap[file] ?? []}
|
||||
hasUpdate={stackUpdates[file]?.hasUpdate ?? false}
|
||||
outdatedServices={(stackUpdates[file]?.services ?? [])
|
||||
.filter((s) => s.hasUpdate)
|
||||
.map((s) => s.service)}
|
||||
checkStatus={stackUpdates[file]?.checkStatus}
|
||||
lastError={stackUpdates[file]?.lastError ?? undefined}
|
||||
hasGitPending={!!gitSourcePendingMap[file]}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
|
||||
import { statusText, statusColor } from './stack-status-utils';
|
||||
import type { StackRowStatus } from './stack-status-utils';
|
||||
import { updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
|
||||
interface StackRowProps {
|
||||
file: string;
|
||||
@@ -20,6 +21,8 @@ interface StackRowProps {
|
||||
isActive: boolean;
|
||||
labels: Label[];
|
||||
hasUpdate: boolean;
|
||||
/** Outdated service names for the update tooltip; empty keeps the generic label. */
|
||||
outdatedServices?: string[];
|
||||
// Last image-update check outcome. 'failed' surfaces a muted "couldn't check"
|
||||
// indicator so an undeterminable check is not mistaken for "up to date".
|
||||
checkStatus?: CheckStatus;
|
||||
@@ -48,7 +51,7 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
|
||||
export function StackRow(props: StackRowProps) {
|
||||
const {
|
||||
file, displayName, status, running, total, isBusy, isActive,
|
||||
hasUpdate, checkStatus, lastError, hasGitPending, onSelect, kebabSlot,
|
||||
hasUpdate, outdatedServices, checkStatus, lastError, hasGitPending, onSelect, kebabSlot,
|
||||
bulkMode = false, isSelected = false, onToggleSelect,
|
||||
} = props;
|
||||
|
||||
@@ -113,7 +116,7 @@ export function StackRow(props: StackRowProps) {
|
||||
<span className="relative w-2 h-2 rounded-full bg-update" />
|
||||
</span>
|
||||
)}
|
||||
label="Update available"
|
||||
label={updateAvailableLabel(outdatedServices)}
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
<RowTooltip
|
||||
|
||||
@@ -116,6 +116,12 @@ describe('StackRow', () => {
|
||||
expect(container.querySelector('.bg-update')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('names outdated services in the update tooltip', async () => {
|
||||
render(<StackRow {...base({ hasUpdate: true, outdatedServices: ['api', 'worker'] })} />);
|
||||
fireEvent.pointerMove(screen.getByTestId('stack-trailing-update'));
|
||||
expect((await screen.findAllByText('Update available: api, worker')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows no trailing indicator for a clean ok check with no update', () => {
|
||||
const { container } = render(<StackRow {...base({ status: 'running', hasUpdate: false, checkStatus: 'ok' })} />);
|
||||
expect(container.querySelector('.lucide-alert-circle')).toBeNull();
|
||||
|
||||
@@ -97,6 +97,10 @@ interface UpdateReadinessDialogProps {
|
||||
* while the dialog is open cannot mismatch the readiness from the update.
|
||||
*/
|
||||
nodeId: number | null;
|
||||
/** Set only for a service-scoped update; absent means the full stack. */
|
||||
serviceName?: string;
|
||||
/** Update vs rebuild copy for a service-scoped update. Ignored for the full stack. */
|
||||
mode?: 'update' | 'rebuild';
|
||||
onCancel: () => void;
|
||||
/** Caller closes the dialog and starts the update. */
|
||||
onProceed: () => void;
|
||||
@@ -108,7 +112,7 @@ interface UpdateReadinessDialogProps {
|
||||
* the single hard block. A slow or failed readiness fetch degrades to an
|
||||
* unknown verdict so this dialog can never strand the update path.
|
||||
*/
|
||||
export function UpdateReadinessDialog({ open, stackName, nodeId, onCancel, onProceed }: UpdateReadinessDialogProps) {
|
||||
export function UpdateReadinessDialog({ open, stackName, nodeId, serviceName, mode = 'update', onCancel, onProceed }: UpdateReadinessDialogProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [report, setReport] = useState<UpdateReadinessReport | null>(null);
|
||||
@@ -136,7 +140,10 @@ export function UpdateReadinessDialog({ open, stackName, nodeId, onCancel, onPro
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/update-readiness`, { nodeId, signal: controller.signal });
|
||||
const path = serviceName
|
||||
? `/stacks/${stackName}/update-readiness?service=${encodeURIComponent(serviceName)}`
|
||||
: `/stacks/${stackName}/update-readiness`;
|
||||
const res = await apiFetch(path, { nodeId, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const unreachable = res.status === 502 || res.status === 503 || res.status === 504;
|
||||
setReport(UNKNOWN_FALLBACK(unreachable
|
||||
@@ -184,7 +191,7 @@ export function UpdateReadinessDialog({ open, stackName, nodeId, onCancel, onPro
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, stackName, nodeId, isAdmin]);
|
||||
}, [open, stackName, nodeId, isAdmin, serviceName]);
|
||||
|
||||
const proceed = async () => {
|
||||
if (snapshotFirst) {
|
||||
@@ -221,9 +228,11 @@ export function UpdateReadinessDialog({ open, stackName, nodeId, onCancel, onPro
|
||||
return (
|
||||
<Modal open={open} onOpenChange={(next) => { if (!next && !working) onCancel(); }} size="lg">
|
||||
<ModalHeader
|
||||
kicker={`${stackName.toUpperCase()} · UPDATE READINESS`}
|
||||
title="Ready to update?"
|
||||
description="A pre-update check of this stack's preflight, drift, containers, backup, and pending image change."
|
||||
kicker={serviceName ? `${stackName.toUpperCase()} / ${serviceName.toUpperCase()} · ${mode === 'rebuild' ? 'REBUILD' : 'UPDATE'} READINESS` : `${stackName.toUpperCase()} · UPDATE READINESS`}
|
||||
title={serviceName ? (mode === 'rebuild' ? 'Ready to rebuild?' : 'Ready to update?') : 'Ready to update?'}
|
||||
description={serviceName
|
||||
? `A pre-${mode} check of this stack's preflight, drift, containers, backup, and pending image change. This service is checked in the context of the whole stack.`
|
||||
: "A pre-update check of this stack's preflight, drift, containers, backup, and pending image change."}
|
||||
/>
|
||||
<ModalBody>
|
||||
{!report || !verdict || !VerdictIcon ? (
|
||||
@@ -297,7 +306,7 @@ export function UpdateReadinessDialog({ open, stackName, nodeId, onCancel, onPro
|
||||
void proceed();
|
||||
}}
|
||||
>
|
||||
{working ? 'Creating snapshot…' : 'Update now'}
|
||||
{working ? 'Creating snapshot…' : (mode === 'rebuild' ? 'Rebuild now' : 'Update now')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { apiFetch } from '../lib/api';
|
||||
import { type ParsedLogRow, parseLogChunk } from '../components/log-rendering/composeLogParser';
|
||||
import { useDeployFeedbackEnabled } from '../hooks/use-deploy-feedback-enabled';
|
||||
import { readDeployFeedbackStyle } from '../hooks/use-deploy-feedback-style';
|
||||
import { toast } from '../components/ui/toast-store';
|
||||
import { fetchActiveServiceRecovery, requestServiceRestore } from '../lib/serviceUpdate';
|
||||
|
||||
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install' | 'scan';
|
||||
|
||||
@@ -59,6 +61,8 @@ interface RunResult {
|
||||
* node never returns one, so no gate UI appears.
|
||||
*/
|
||||
healthGateId?: string | null;
|
||||
/** Service-scoped recovery snapshot id, when one was captured. */
|
||||
recoveryId?: string | null;
|
||||
}
|
||||
|
||||
/** Post-update health gate state for the current deploy session. */
|
||||
@@ -74,6 +78,14 @@ export interface HealthGateUiState {
|
||||
reason: string | null;
|
||||
windowSeconds: number | null;
|
||||
startedAt: number | null;
|
||||
/** 'service' for a service-scoped update/restore gate; 'stack' otherwise. Absent on older gates. */
|
||||
targetScope?: 'stack' | 'service';
|
||||
/** Set only for a service-scoped gate; null/absent for a full-stack gate. */
|
||||
serviceName?: string | null;
|
||||
/** Which side of a service-scoped gate failed; null/absent for full-stack gates and non-failures. */
|
||||
failureSource?: 'primary' | 'collateral' | null;
|
||||
/** Recovery snapshot to offer restore after a failed service gate. */
|
||||
recoveryId?: string | null;
|
||||
}
|
||||
|
||||
const GATE_POLL_INTERVAL_MS = 4_000;
|
||||
@@ -84,6 +96,8 @@ export interface RunWithLogParams {
|
||||
action: ActionVerb;
|
||||
/** Node the operation runs on (null = local), for node-scoped surfaces. */
|
||||
nodeId: number | null;
|
||||
/** When set, this is a service-scoped update/restore; gate polling uses targetScope=service. */
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
interface DeployFeedbackContextValue {
|
||||
@@ -164,9 +178,22 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
|
||||
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
|
||||
|
||||
// Poll timer for the current session's health gate; cleared on panel close
|
||||
// and whenever a new session starts.
|
||||
// Poll timer for the current session's health gate; cleared when a watch
|
||||
// ends or a newer session starts. Closing the panel no longer stops a
|
||||
// service-scoped watch: recovery must stay discoverable after dismiss.
|
||||
const gatePollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const healthGateRef = useRef<HealthGateUiState | null>(null);
|
||||
const panelOpenRef = useRef(false);
|
||||
/** True when gate polling runs without the Deploy Progress panel (disabled setting or after dismiss). */
|
||||
const silentGateRef = useRef(false);
|
||||
const offerRestoreToastRef = useRef<(gate: HealthGateUiState) => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
healthGateRef.current = healthGate;
|
||||
}, [healthGate]);
|
||||
useEffect(() => {
|
||||
panelOpenRef.current = panelState.isOpen;
|
||||
}, [panelState.isOpen]);
|
||||
|
||||
const stopGatePolling = useCallback(() => {
|
||||
if (gatePollRef.current !== null) {
|
||||
@@ -245,16 +272,31 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
}, []);
|
||||
|
||||
const onPanelClose = useCallback(() => {
|
||||
sessionIdRef.current += 1;
|
||||
const gate = healthGateRef.current;
|
||||
const keepServiceWatch = !!gate
|
||||
&& gate.targetScope === 'service'
|
||||
&& (gate.status === 'observing' || gate.status === 'failed');
|
||||
|
||||
settleStartRef.current = null;
|
||||
streamReadyRef.current = false;
|
||||
// The gate keeps observing server-side; only this session's poll stops.
|
||||
stopGatePolling();
|
||||
setHealthGate(null);
|
||||
setPanelState(DEFAULT_PANEL_STATE);
|
||||
setMinimized(false);
|
||||
setBannerActive(false);
|
||||
setLogRows([]);
|
||||
|
||||
if (keepServiceWatch) {
|
||||
// Keep polling (or a failed gate with recovery) so Restore stays reachable.
|
||||
silentGateRef.current = true;
|
||||
if (gate.status === 'failed') {
|
||||
offerRestoreToastRef.current(gate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sessionIdRef.current += 1;
|
||||
silentGateRef.current = false;
|
||||
stopGatePolling();
|
||||
setHealthGate(null);
|
||||
}, [stopGatePolling]);
|
||||
|
||||
// Poll the by-id gate endpoint until a terminal status. The id-scoped read
|
||||
@@ -262,9 +304,24 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
// instead of this session showing a newer run's result. Mirrors the backend
|
||||
// gate's own degradation: repeated failures (or an absurdly long poll)
|
||||
// resolve to an honest client-side unknown rather than observing forever.
|
||||
const startGatePolling = useCallback((stackName: string, nodeId: number | null, gateId: string, trigger: 'update' | 'deploy', mySession: number) => {
|
||||
const startGatePolling = useCallback((
|
||||
stackName: string,
|
||||
nodeId: number | null,
|
||||
gateId: string,
|
||||
trigger: 'update' | 'deploy',
|
||||
mySession: number,
|
||||
options?: { serviceName?: string; recoveryId?: string | null; silent?: boolean },
|
||||
) => {
|
||||
stopGatePolling();
|
||||
setHealthGate({ stackName, nodeId, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null });
|
||||
silentGateRef.current = options?.silent === true;
|
||||
const targetScope = options?.serviceName ? 'service' : 'stack';
|
||||
setHealthGate({
|
||||
stackName, nodeId, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null,
|
||||
targetScope,
|
||||
serviceName: options?.serviceName ?? null,
|
||||
failureSource: null,
|
||||
recoveryId: options?.recoveryId ?? null,
|
||||
});
|
||||
let strikes = 0;
|
||||
// Single-flight: skip a tick while one request is outstanding so two
|
||||
// overlapping responses cannot land out of order.
|
||||
@@ -299,6 +356,9 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
reason: string | null;
|
||||
windowSeconds: number | null;
|
||||
startedAt: number | null;
|
||||
targetScope?: 'stack' | 'service';
|
||||
serviceName?: string | null;
|
||||
failureSource?: 'primary' | 'collateral' | null;
|
||||
}
|
||||
: null;
|
||||
if (sessionIdRef.current !== mySession || settled) return;
|
||||
@@ -311,10 +371,33 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
return;
|
||||
}
|
||||
strikes = 0;
|
||||
setHealthGate({ stackName, nodeId, gateId, trigger, status: report.status, reason: report.reason, windowSeconds: report.windowSeconds, startedAt: report.startedAt });
|
||||
if (report.status !== 'observing') {
|
||||
const status: HealthGateUiState['status'] =
|
||||
report.status === 'observing' || report.status === 'passed'
|
||||
|| report.status === 'failed' || report.status === 'unknown'
|
||||
? report.status
|
||||
: 'unknown';
|
||||
const nextGate: HealthGateUiState = {
|
||||
stackName, nodeId, gateId, trigger,
|
||||
status,
|
||||
reason: report.reason,
|
||||
windowSeconds: report.windowSeconds, startedAt: report.startedAt,
|
||||
targetScope: report.targetScope ?? targetScope,
|
||||
serviceName: report.serviceName ?? options?.serviceName ?? null,
|
||||
failureSource: report.failureSource ?? null,
|
||||
recoveryId: healthGateRef.current?.recoveryId ?? options?.recoveryId ?? null,
|
||||
};
|
||||
setHealthGate(nextGate);
|
||||
if (status !== 'observing') {
|
||||
settled = true;
|
||||
stopGatePolling();
|
||||
if (
|
||||
status === 'failed'
|
||||
&& nextGate.targetScope === 'service'
|
||||
&& nextGate.serviceName
|
||||
&& (silentGateRef.current || !panelOpenRef.current)
|
||||
) {
|
||||
offerRestoreToastRef.current(nextGate);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
strikes += 1;
|
||||
@@ -328,6 +411,76 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
gatePollRef.current = setInterval(() => { void tick(); }, GATE_POLL_INTERVAL_MS);
|
||||
}, [stopGatePolling]);
|
||||
|
||||
// Keep the toast helper current without re-creating startGatePolling on every render.
|
||||
useEffect(() => {
|
||||
offerRestoreToastRef.current = (gate: HealthGateUiState) => {
|
||||
const serviceName = gate.serviceName;
|
||||
if (!serviceName) return;
|
||||
toast.error(
|
||||
`Health gate failed for service "${serviceName}"${gate.reason ? `: ${gate.reason}` : ''}.`,
|
||||
{
|
||||
duration: 120_000,
|
||||
action: {
|
||||
label: 'Restore',
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
let recoveryId = gate.recoveryId ?? null;
|
||||
if (!recoveryId) {
|
||||
const lookup = await fetchActiveServiceRecovery({
|
||||
nodeId: gate.nodeId,
|
||||
stackName: gate.stackName,
|
||||
serviceName,
|
||||
});
|
||||
if (!lookup.ok) {
|
||||
toast.error(lookup.error);
|
||||
return;
|
||||
}
|
||||
recoveryId = lookup.recovery?.id ?? null;
|
||||
}
|
||||
if (!recoveryId) {
|
||||
toast.error(`No recovery snapshot is available for "${serviceName}".`);
|
||||
return;
|
||||
}
|
||||
const loadingId = toast.loading(`Restoring "${serviceName}"...`);
|
||||
try {
|
||||
const result = await requestServiceRestore({
|
||||
nodeId: gate.nodeId,
|
||||
stackName: gate.stackName,
|
||||
serviceName,
|
||||
recoveryId,
|
||||
});
|
||||
toast.dismiss(loadingId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${serviceName}" restored. Verifying health...`);
|
||||
sessionIdRef.current += 1;
|
||||
startGatePolling(
|
||||
gate.stackName,
|
||||
gate.nodeId,
|
||||
result.healthGateId,
|
||||
'update',
|
||||
sessionIdRef.current,
|
||||
{ serviceName, recoveryId: result.recoveryId, silent: true },
|
||||
);
|
||||
} else {
|
||||
toast.success(`Service "${serviceName}" restored successfully`);
|
||||
setHealthGate(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(loadingId);
|
||||
toast.error(error instanceof Error ? error.message : `Failed to restore "${serviceName}"`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}, [startGatePolling]);
|
||||
|
||||
const runWithLog = useCallback(
|
||||
async (
|
||||
params: RunWithLogParams,
|
||||
@@ -344,7 +497,26 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
const deploySessionId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
if (!isEnabled) {
|
||||
return run(Promise.resolve(), deploySessionId);
|
||||
const result = await run(Promise.resolve(), deploySessionId);
|
||||
// Service-scoped updates still need gate polling and Restore discovery
|
||||
// when Deploy Progress is off; open no panel, watch silently.
|
||||
if (
|
||||
result.ok
|
||||
&& result.healthGateId
|
||||
&& params.serviceName
|
||||
&& (params.action === 'update' || params.action === 'deploy')
|
||||
) {
|
||||
sessionIdRef.current += 1;
|
||||
startGatePolling(
|
||||
params.stackName,
|
||||
params.nodeId,
|
||||
result.healthGateId,
|
||||
params.action === 'deploy' ? 'deploy' : 'update',
|
||||
sessionIdRef.current,
|
||||
{ serviceName: params.serviceName, recoveryId: result.recoveryId, silent: true },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Read the persisted style synchronously so this deploy uses the style in
|
||||
@@ -419,7 +591,10 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
errorMessage: result.ok ? undefined : result.errorMessage,
|
||||
}));
|
||||
if (result.ok && result.healthGateId && (params.action === 'update' || params.action === 'deploy')) {
|
||||
startGatePolling(params.stackName, params.nodeId, result.healthGateId, params.action, mySession);
|
||||
startGatePolling(params.stackName, params.nodeId, result.healthGateId, params.action, mySession, {
|
||||
serviceName: params.serviceName,
|
||||
recoveryId: result.recoveryId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ describe('DeployFeedbackContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('releases the deploy when the progress stream fails before connecting', async () => {
|
||||
@@ -149,6 +151,90 @@ describe('DeployFeedbackContext', () => {
|
||||
expect(result.current.panelState.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('silently polls a service health gate when Deploy Progress is disabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
|
||||
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
|
||||
if (String(url).includes('/health-gate')) {
|
||||
return new Response(JSON.stringify({
|
||||
id: 'gate-svc', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now(),
|
||||
targetScope: 'service', serviceName: 'api', failureSource: null,
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runWithLog(
|
||||
{ stackName: 'web', action: 'update', nodeId: null, serviceName: 'api' },
|
||||
async (started) => {
|
||||
await started;
|
||||
return { ok: true, healthGateId: 'gate-svc', recoveryId: 'rec-1' };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.panelState.isOpen).toBe(false);
|
||||
expect(result.current.healthGate).toMatchObject({
|
||||
gateId: 'gate-svc', serviceName: 'api', recoveryId: 'rec-1', status: 'observing',
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(4_000); });
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/stacks/web/health-gate?gateId=gate-svc'),
|
||||
expect.anything(),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps service gate recovery after the panel is closed', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
|
||||
if (String(url).includes('/health-gate')) {
|
||||
return new Response(JSON.stringify({
|
||||
id: 'gate-svc', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now(),
|
||||
targetScope: 'service', serviceName: 'api', failureSource: null,
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog(
|
||||
{ stackName: 'web', action: 'update', nodeId: null, serviceName: 'api' },
|
||||
async (started) => {
|
||||
await started;
|
||||
return { ok: true, healthGateId: 'gate-svc', recoveryId: 'rec-keep' };
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// onTerminalReady schedules the deploy gate release after 50ms.
|
||||
await act(async () => {
|
||||
result.current.onTerminalReady();
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
await outer;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.healthGate?.recoveryId).toBe('rec-keep');
|
||||
|
||||
act(() => { result.current.onPanelClose(); });
|
||||
expect(result.current.panelState.isOpen).toBe(false);
|
||||
expect(result.current.healthGate).toMatchObject({
|
||||
gateId: 'gate-svc', recoveryId: 'rec-keep', status: 'observing',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('caps log rows and marks the truncation point', () => {
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { StackUpdateInfo, StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
/**
|
||||
* Per-service update status for one stack, selected from the
|
||||
* `useImageUpdates` map (`detail.services`). Returns an empty array when the
|
||||
* stack has no persisted per-service breakdown yet (older check, or a stack
|
||||
* that has never been checked).
|
||||
*/
|
||||
export function useServiceUpdateStatus(
|
||||
stackUpdates: Record<string, StackUpdateInfo>,
|
||||
stackFile: string | null,
|
||||
): StackServiceUpdateStatus[] {
|
||||
return useMemo(() => {
|
||||
if (!stackFile) return [];
|
||||
return stackUpdates[stackFile]?.services ?? [];
|
||||
}, [stackUpdates, stackFile]);
|
||||
}
|
||||
|
||||
/** Look up one service's status by name from a per-stack breakdown. */
|
||||
export function findServiceUpdateStatus(
|
||||
services: StackServiceUpdateStatus[],
|
||||
serviceName: string,
|
||||
): StackServiceUpdateStatus | undefined {
|
||||
return services.find((s) => s.service === serviceName);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
|
||||
describe('updateAvailableLabel', () => {
|
||||
it('uses the generic label when no services are named', () => {
|
||||
expect(updateAvailableLabel()).toBe('Update available');
|
||||
expect(updateAvailableLabel([])).toBe('Update available');
|
||||
});
|
||||
|
||||
it('names one or a few outdated services', () => {
|
||||
expect(updateAvailableLabel(['api'])).toBe('Update available: api');
|
||||
expect(updateAvailableLabel(['api', 'db', 'worker'])).toBe('Update available: api, db, worker');
|
||||
});
|
||||
|
||||
it('summarizes larger service sets by count', () => {
|
||||
expect(updateAvailableLabel(['a', 'b', 'c', 'd'])).toBe('Update available: 4 services');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAvailableBadge', () => {
|
||||
it('stays compact for the Stack Health column', () => {
|
||||
expect(updateAvailableBadge(['api'])).toBe('Update: api');
|
||||
expect(updateAvailableBadge(['api', 'db'])).toBe('2 updates');
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,11 @@ export const CAPABILITIES = [
|
||||
'cross-node-rbac',
|
||||
'stack-down-remove-volumes',
|
||||
'guided-external-network-preflight',
|
||||
'service-scoped-update',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
||||
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
|
||||
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { apiFetch, withDeploySession } from './api';
|
||||
|
||||
export interface RequestServiceUpdateParams {
|
||||
nodeId: number | null;
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
/** Caller's intent only (Update vs Rebuild copy); the backend route and
|
||||
* orchestrator path are the same either way. Defaults to 'update'. */
|
||||
mode?: 'update' | 'rebuild';
|
||||
/** Deploy-feedback session id so Compose output streams to the panel. */
|
||||
deploySessionId?: string;
|
||||
}
|
||||
|
||||
export interface ServiceUpdateSuccess {
|
||||
ok: true;
|
||||
mode: 'update' | 'rebuild';
|
||||
serviceName: string;
|
||||
healthGateId: string | null;
|
||||
observing: boolean;
|
||||
recoveryId: string | null;
|
||||
recoveryAvailable: boolean;
|
||||
recheckWarning?: string;
|
||||
}
|
||||
|
||||
export interface ServiceUpdateFailure {
|
||||
ok: false;
|
||||
mode: 'update' | 'rebuild';
|
||||
error: string;
|
||||
code?: string;
|
||||
serviceName?: string;
|
||||
mutationStage?: string;
|
||||
recoveryId?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type ServiceUpdateResult = ServiceUpdateSuccess | ServiceUpdateFailure;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
/**
|
||||
* Single entry point for a manual service-scoped update/rebuild, mirroring
|
||||
* `POST /stacks/:stackName/services/:serviceName/update`'s response shape
|
||||
* (see `sendServiceResult` in backend/src/routes/stacks.ts).
|
||||
*/
|
||||
export async function requestServiceUpdate(params: RequestServiceUpdateParams): Promise<ServiceUpdateResult> {
|
||||
const { nodeId, stackName, serviceName, mode = 'update', deploySessionId } = params;
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${encodeURIComponent(stackName)}/services/${encodeURIComponent(serviceName)}/update`,
|
||||
withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId }),
|
||||
);
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const error = isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `Failed to update service "${serviceName}"`;
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
error,
|
||||
code: isRecord(body) && typeof body.code === 'string' ? body.code : undefined,
|
||||
serviceName: isRecord(body) && typeof body.serviceName === 'string' ? body.serviceName : undefined,
|
||||
mutationStage: isRecord(body) && typeof body.mutationStage === 'string' ? body.mutationStage : undefined,
|
||||
recoveryId: isRecord(body) && typeof body.recoveryId === 'string' ? body.recoveryId : undefined,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
if (!isRecord(body) || typeof body.serviceName !== 'string') {
|
||||
return { ok: false, mode, error: 'Unexpected response from the service update', status: res.status };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode,
|
||||
serviceName: body.serviceName,
|
||||
healthGateId: typeof body.healthGateId === 'string' ? body.healthGateId : null,
|
||||
observing: body.observing === true,
|
||||
recoveryId: typeof body.recoveryId === 'string' ? body.recoveryId : null,
|
||||
recoveryAvailable: body.recoveryAvailable === true,
|
||||
recheckWarning: typeof body.recheckWarning === 'string' ? body.recheckWarning : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
error: error instanceof Error ? error.message : `Failed to update service "${serviceName}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestServiceRestoreParams {
|
||||
nodeId: number | null;
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
recoveryId: string;
|
||||
deploySessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one Compose service from a recovery snapshot captured during a
|
||||
* prior service-scoped update (`POST .../services/:serviceName/restore`).
|
||||
*/
|
||||
export async function requestServiceRestore(params: RequestServiceRestoreParams): Promise<ServiceUpdateResult> {
|
||||
const { nodeId, stackName, serviceName, recoveryId, deploySessionId } = params;
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${encodeURIComponent(stackName)}/services/${encodeURIComponent(serviceName)}/restore`,
|
||||
withDeploySession(deploySessionId ?? '', {
|
||||
method: 'POST',
|
||||
nodeId,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recoveryId }),
|
||||
}),
|
||||
);
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const error = isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `Failed to restore service "${serviceName}"`;
|
||||
return {
|
||||
ok: false,
|
||||
mode: 'update',
|
||||
error,
|
||||
code: isRecord(body) && typeof body.code === 'string' ? body.code : undefined,
|
||||
serviceName: isRecord(body) && typeof body.serviceName === 'string' ? body.serviceName : undefined,
|
||||
mutationStage: isRecord(body) && typeof body.mutationStage === 'string' ? body.mutationStage : undefined,
|
||||
recoveryId: isRecord(body) && typeof body.recoveryId === 'string' ? body.recoveryId : undefined,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
if (!isRecord(body) || typeof body.serviceName !== 'string') {
|
||||
return { ok: false, mode: 'update', error: 'Unexpected response from the service restore', status: res.status };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: body.serviceName,
|
||||
healthGateId: typeof body.healthGateId === 'string' ? body.healthGateId : null,
|
||||
observing: body.observing === true,
|
||||
recoveryId: typeof body.recoveryId === 'string' ? body.recoveryId : null,
|
||||
recoveryAvailable: body.recoveryAvailable === true,
|
||||
recheckWarning: typeof body.recheckWarning === 'string' ? body.recheckWarning : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: 'update',
|
||||
error: error instanceof Error ? error.message : `Failed to restore service "${serviceName}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveServiceRecovery {
|
||||
id: string;
|
||||
status: string;
|
||||
healthGateId: string | null;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
majorityImageId: string;
|
||||
declaredImageRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the newest active recovery snapshot for a service, if any. Used when
|
||||
* Deploy Progress is disabled or dismissed so Restore remains discoverable.
|
||||
* Distinguishes "none" from lookup failure so the UI does not claim the
|
||||
* snapshot is missing when the request actually failed.
|
||||
*/
|
||||
export type FetchActiveServiceRecoveryResult =
|
||||
| { ok: true; recovery: ActiveServiceRecovery | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function fetchActiveServiceRecovery(params: {
|
||||
nodeId: number | null;
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
}): Promise<FetchActiveServiceRecoveryResult> {
|
||||
const { nodeId, stackName, serviceName } = params;
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${encodeURIComponent(stackName)}/services/${encodeURIComponent(serviceName)}/recovery`,
|
||||
{ method: 'GET', nodeId },
|
||||
);
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const error = isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `Failed to look up recovery for "${serviceName}"`;
|
||||
console.warn('[serviceUpdate] recovery lookup failed:', res.status, error);
|
||||
return { ok: false, error };
|
||||
}
|
||||
if (!isRecord(body)) return { ok: true, recovery: null };
|
||||
if (body.recovery === null || body.recovery === undefined) {
|
||||
return { ok: true, recovery: null };
|
||||
}
|
||||
if (!isRecord(body.recovery)) {
|
||||
console.warn('[serviceUpdate] recovery lookup returned an unexpected payload');
|
||||
return { ok: false, error: `Unexpected recovery response for "${serviceName}"` };
|
||||
}
|
||||
const row = body.recovery;
|
||||
const id = row.id;
|
||||
if (typeof id !== 'string') {
|
||||
console.warn('[serviceUpdate] recovery lookup returned an unexpected payload');
|
||||
return { ok: false, error: `Unexpected recovery response for "${serviceName}"` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
recovery: {
|
||||
id,
|
||||
status: typeof row.status === 'string' ? row.status : 'active',
|
||||
healthGateId: typeof row.healthGateId === 'string' ? row.healthGateId : null,
|
||||
expiresAt: typeof row.expiresAt === 'number' ? row.expiresAt : 0,
|
||||
createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0,
|
||||
majorityImageId: typeof row.majorityImageId === 'string' ? row.majorityImageId : '',
|
||||
declaredImageRef: typeof row.declaredImageRef === 'string' ? row.declaredImageRef : '',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to look up recovery for "${serviceName}"`;
|
||||
console.warn('[serviceUpdate] recovery lookup failed:', message);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Tooltip / badge copy naming outdated services when the breakdown is known. */
|
||||
export function updateAvailableLabel(outdatedServices?: string[]): string {
|
||||
const names = (outdatedServices ?? []).filter(Boolean);
|
||||
if (names.length === 0) return 'Update available';
|
||||
if (names.length === 1) return `Update available: ${names[0]}`;
|
||||
if (names.length <= 3) return `Update available: ${names.join(', ')}`;
|
||||
return `Update available: ${names.length} services`;
|
||||
}
|
||||
|
||||
/** Compact Stack Health badge; full detail stays in the title attribute. */
|
||||
export function updateAvailableBadge(outdatedServices?: string[]): string {
|
||||
const names = (outdatedServices ?? []).filter(Boolean);
|
||||
if (names.length === 0) return 'Update available';
|
||||
if (names.length === 1) return `Update: ${names[0]}`;
|
||||
return `${names.length} updates`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Per-service facts from the fully-merged effective Compose model, as
|
||||
* returned by `GET /api/stacks/:stackName/effective-services`. Mirrors
|
||||
* `backend/src/services/effectiveServiceModel.ts`; service-scoped update
|
||||
* gating (multi-service headers, eligible-for-update, rebuild vs update
|
||||
* wording, expected replica count) reads these fields.
|
||||
*/
|
||||
export interface EffectiveServiceSpec {
|
||||
name: string;
|
||||
declaredImage: string | null;
|
||||
hasBuild: boolean;
|
||||
/** May be 0 (explicit `scale: 0` or `deploy.replicas: 0`); defaults to 1 when neither is set. */
|
||||
expectedReplicas: number;
|
||||
dependsOn: string[];
|
||||
hasHealthcheck: boolean;
|
||||
}
|
||||
|
||||
export type EffectiveServiceModelResult =
|
||||
| { renderable: true; services: EffectiveServiceSpec[] }
|
||||
| { renderable: false; code: 'effective_model_render_failed'; error: string };
|
||||
@@ -34,6 +34,23 @@ export interface ImageUpdateStatus {
|
||||
*/
|
||||
export type CheckStatus = 'ok' | 'partial' | 'failed';
|
||||
|
||||
/**
|
||||
* Per-service check outcome, as returned in `StackUpdateInfo.services`.
|
||||
* Mirrors the backend's `StackServiceStatus`. Distinct from `CheckStatus`:
|
||||
* a service with no checkable image (build-only, no declared image) is
|
||||
* `not_checkable`, which never counts as a check failure at the stack level.
|
||||
*/
|
||||
export type ServiceCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
|
||||
export interface StackServiceUpdateStatus {
|
||||
service: string;
|
||||
image: string | null;
|
||||
runtimeImages?: string[];
|
||||
hasUpdate: boolean;
|
||||
checkStatus: ServiceCheckStatus;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich per-stack update status from `GET /api/image-updates/detail`. `lastError`
|
||||
* carries the failure reason when `checkStatus` is 'failed' or 'partial'.
|
||||
@@ -43,4 +60,6 @@ export interface StackUpdateInfo {
|
||||
checkStatus: CheckStatus;
|
||||
lastError: string | null;
|
||||
checkedAt: number;
|
||||
/** Per-service breakdown; absent when the stack has no persisted per-service data yet. */
|
||||
services?: StackServiceUpdateStatus[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user