feat(recovery): make rollback-recovery image lifecycle visible and controllable (#1753)

* feat(recovery): make rollback-recovery image lifecycle visible and controllable

GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold
images during automatic updates and how to clean them up. That surfaced a real
safety bug alongside the missing visibility: the manual single-image delete
route did not consult the held-image predicate every other deletion path
already honors, so a user could delete a rollback-protected image straight
through the Images tab and silently break automatic recovery for that update.
A short/truncated id also bypassed the predicate's full-id lookup.

Fixes:
- POST /images/delete now resolves the submitted id to its canonical form and
  checks the unified held-image predicate before deleting, returning 409
  IMAGE_HELD_FOR_ROLLBACK for a protected image.
- The Images tab no longer mislabels a protected image as plain "Unused"; a
  fully-synthetic hold image is kept out of the generic inventory entirely and
  surfaced instead in a new Resources -> Rollback tab, with an additive
  "Rollback protected" badge for images that still carry a normal tag too.

New capability:
- Two settings (Deploy Guardrails): superseded-generation retention (days,
  replaces a hardcoded 7) and a cap on retained generations per stack.
- A new Resources -> Rollback tab lists every generation (stack, short id,
  state, retention) with an admin-gated manual release action, including
  releasing the current generation with an explicit warning that automatic
  rollback becomes unavailable until the next successful update. Release is
  a single atomic, server-revalidated transition so a stale UI read can never
  release a row that has since become ineligible.

Also consolidated three near-duplicate implementations of the held-image
predicate (two of which relied on a require() of a sibling .ts file that
silently failed to resolve under the test runner and was never actually
exercised by a real test before this change) into one shared module.

Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row
no longer exists (DB restore, node re-add) is invisible in both the Images
and Rollback tabs with no UI path to reclaim it.

* fix(audit): add summary mapping for rollback generation release

* fix(security): sanitize prune target in log sinks and cover release RBAC

Closes two open js/log-injection findings on the system prune route by
applying the same inline sanitizeForLog barrier the rest of the file
already uses. The prune target is validated against an enum by
parsePruneTargets before reaching these sinks, so the findings were false
positives, but the barrier is cheap and removes the standing alerts on a
file this change already touches. Also wraps the generation id in the
release log line for consistency with the stack name beside it.

Adds coverage for gaps a QA pass identified:
- Release endpoint refuses a viewer and a deployer (Admin-only), leaving
  the generation and its artifacts untouched.
- Viewer can still read the generations list, matching the sibling
  Resources routes.
- The predicate the prune routes build reports full-stack rollback holds,
  not just service-scoped ones, and re-reads per call so a hold taken
  between plan and delete still gates the delete.
- After releasing the current generation, no rollback point is claimed
  for the stack through any consumer of the current-generation lookup.
This commit is contained in:
Anso
2026-08-02 21:55:22 -04:00
committed by GitHub
parent 97be019696
commit 41bf075eb0
31 changed files with 1734 additions and 93 deletions
+50 -20
View File
@@ -29,6 +29,8 @@ import { cn } from '@/lib/utils';
import { ReclaimHero } from './resources/ReclaimHero';
import { FootprintTreemap } from './resources/FootprintTreemap';
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
import { RollbackGenerationsTab, type RollbackGeneration } from './resources/RollbackGenerationsTab';
import { TableSkeleton } from './resources/TableSkeleton';
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { VolumeNameLabel } from './resources/VolumeNameLabel';
import { useTableSort } from '@/hooks/useTableSort';
@@ -59,6 +61,9 @@ interface DockerImage {
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'unused';
isSencho: boolean;
/** True when a rollback hold protects this image from pruning; additive, independent of managedStatus. */
rollbackProtected: boolean;
rollbackProtectionKind?: 'stack' | 'service';
}
interface DockerVolume {
@@ -272,6 +277,26 @@ function SenchoBadge() {
);
}
function RollbackProtectedBadge({ kind }: { kind?: 'stack' | 'service' }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px] h-5 gap-1 border-brand/40 text-brand">
<ShieldCheck className="w-3 h-3" strokeWidth={2} />
Rollback protected
</Badge>
</TooltipTrigger>
<TooltipContent>
{kind === 'stack'
? 'Held as a full-stack rollback point. See Resources → Rollback.'
: 'Held for a pending per-service update rollback.'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// ── Severity Badge ─────────────────────────────────────────────────────────────
// ── Quick Clean Prune Button ───────────────────────────────────────────────────
@@ -327,24 +352,6 @@ function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: Pru
);
}
// ── Table Skeleton ─────────────────────────────────────────────────────────────
function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
return (
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="animate-in fade-in-0" style={{ animationDelay: `${r * 40}ms` }}>
{Array.from({ length: cols }).map((_, c) => (
<TableCell key={c}>
<Skeleton className={cn('h-4', c === 0 ? 'w-24' : c === 1 ? 'w-48' : 'w-16')} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
// Stable comparator maps for the resource tables (module scope so useTableSort
// does not re-sort on every render). Mirrors the Security Images sort standard.
const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = {
@@ -366,7 +373,7 @@ interface ResourcesViewProps {
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
const isMobile = useIsMobile();
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged' | 'rollback'>('images');
const { isAdmin, can } = useAuth();
const canReadResources = can('stack:read');
const canDeployResources = can('stack:deploy');
@@ -377,6 +384,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
const [networks, setNetworks] = useState<DockerNetwork[]>([]);
const [orphans, setOrphans] = useState<Record<string, UnmanagedContainer[]>>({});
const [rollbackGenerations, setRollbackGenerations] = useState<RollbackGeneration[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isActioning, setIsActioning] = useState(false);
@@ -438,12 +446,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const generation = ++fetchGenerationRef.current;
setIsLoading(true);
try {
const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes] = await Promise.all([
const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes, rollbackRes] = await Promise.all([
apiFetch('/system/docker-df'),
apiFetch('/system/resources'),
apiFetch('/system/orphans'),
apiFetch('/security/image-summaries').catch(() => null),
apiFetch('/settings').catch(() => null),
apiFetch('/system/rollback/generations').catch(() => null),
]);
// Resolve every body before the staleness check so a stale
@@ -453,6 +462,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const orphansData = orphansRes.ok ? await orphansRes.json() : null;
const summariesData = summariesRes && summariesRes.ok ? await summariesRes.json() : null;
const settingsData = settingsRes && settingsRes.ok ? await settingsRes.json() : null;
const rollbackData = rollbackRes && rollbackRes.ok ? await rollbackRes.json() : null;
if (fetchGenerationRef.current !== generation) return;
@@ -471,6 +481,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
setSelectedOrphans([]);
}
if (summariesData) setScanSummaries(summariesData);
setRollbackGenerations(Array.isArray(rollbackData) ? rollbackData : []);
} catch (err) {
if (fetchGenerationRef.current !== generation) return;
console.error('Failed to fetch data', err);
@@ -928,6 +939,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{ value: 'images', label: 'Images', count: images.length },
{ value: 'volumes', label: 'Volumes', count: volumes.length },
{ value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount },
{ value: 'rollback', label: 'Rollback', count: rollbackGenerations.length },
]}
/>
) : (
@@ -950,6 +962,12 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{totalOrphansCount}</span>
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="rollback">
<TabsTrigger value="rollback" className="relative">
Rollback
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{rollbackGenerations.length}</span>
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</div>
@@ -1050,6 +1068,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
) : undefined}
/>
{img.isSencho && <SenchoBadge />}
{img.rollbackProtected && <RollbackProtectedBadge kind={img.rollbackProtectionKind} />}
{(() => {
const tag = img.RepoTags?.[0];
const summary = tag ? scanSummaries[tag] : undefined;
@@ -1351,6 +1370,17 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
)}
</div>
</TabsContent>
{/* Rollback */}
<TabsContent value="rollback" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<RollbackGenerationsTab
generations={rollbackGenerations}
isLoading={isLoading}
isAdmin={isAdmin}
nodeId={activeNode?.id}
onReleased={fetchAllData}
/>
</TabsContent>
</div>
</Tabs>
</>
@@ -474,4 +474,41 @@ describe('ResourcesView', () => {
await screen.findByText('off-img:latest');
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
});
it('badges a rollback-protected image without changing its managed/unused status', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/system/resources') {
return Promise.resolve(jsonResponse({
images: [{ ...image('nginx:1.25'), managedStatus: 'unused', rollbackProtected: true, rollbackProtectionKind: 'stack' }],
volumes: [],
networks: [],
}));
}
return Promise.resolve(jsonResponse({}));
});
render(<ResourcesView />);
await screen.findByText('nginx:1.25');
expect(screen.getByText('Rollback protected')).toBeInTheDocument();
expect(screen.getByText('Unused')).toBeInTheDocument();
});
it('shows rollback generations in the Rollback tab, admin-gated release button included', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/system/rollback/generations') {
return Promise.resolve(jsonResponse([
{ id: 'gen-1', shortId: 'abc123456789', stackName: 'seerr', status: 'active', isCurrent: true, phase: 'immediate_verified', createdAt: Date.now(), artifactExpiresAt: null, releasable: true },
]));
}
return Promise.resolve(jsonResponse({}));
});
render(<ResourcesView />);
await userEvent.click(await screen.findByRole('tab', { name: /rollback/i }));
expect(await screen.findByText('seerr')).toBeInTheDocument();
expect(screen.getByText('abc123456789')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeInTheDocument();
});
});
@@ -66,6 +66,7 @@ export type NotificationCategory =
| 'update_started'
| 'health_gate_passed'
| 'health_gate_failed'
| 'rollback_generation_released'
| 'node_update_available'
| 'system';
@@ -0,0 +1,205 @@
import { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ConfirmModal } from '@/components/ui/modal';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Unlock } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
import { TableSkeleton } from './TableSkeleton';
export interface RollbackGeneration {
id: string;
shortId: string;
stackName: string;
status: 'active' | 'restored_current' | 'superseded' | 'recovery_required';
isCurrent: boolean;
phase: string;
createdAt: number;
artifactExpiresAt: number | null;
/** Best-effort UI hint only; the server revalidates eligibility on release. */
releasable: boolean;
}
interface RollbackGenerationsTabProps {
generations: RollbackGeneration[];
isLoading: boolean;
isAdmin: boolean;
nodeId?: number;
/** Refetches the Resources page's data after a successful release. */
onReleased: () => void | Promise<void>;
}
function formatExpiry(gen: RollbackGeneration): string {
if (gen.isCurrent) return 'Protected while current';
if (gen.status === 'recovery_required') return 'Recovery required';
if (gen.artifactExpiresAt === null) return 'Pending';
const days = (gen.artifactExpiresAt - Date.now()) / (24 * 60 * 60 * 1000);
if (days <= 0) return 'Expiring now';
if (days < 1) return `Expires in ${Math.max(1, Math.round(days * 24))}h`;
return `Expires in ${Math.round(days)}d`;
}
function StateBadge({ gen }: { gen: RollbackGeneration }) {
switch (gen.status) {
case 'recovery_required':
return <Badge variant="destructive" className="text-[10px] h-5">Recovery required</Badge>;
case 'superseded':
return <Badge variant="secondary" className="text-[10px] h-5">Superseded</Badge>;
case 'active':
case 'restored_current':
return gen.isCurrent
? <Badge variant="default" className="text-[10px] h-5">Current</Badge>
: <Badge variant="secondary" className="text-[10px] h-5">Superseded</Badge>;
default: {
const unhandled: never = gen.status;
return <Badge variant="secondary" className="text-[10px] h-5">{String(unhandled)}</Badge>;
}
}
}
/**
* Full-stack rollback generations (the sencho-rb/<id>/<service>:hold images
* StackUpdateRecoveryService creates). Kept in its own tab rather than the
* generic Images list: this is durable recovery state with its own lifecycle
* (stack, generation, retention, release), not ordinary Docker image inventory.
*/
export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId, onReleased }: RollbackGenerationsTabProps) {
const [confirmRelease, setConfirmRelease] = useState<RollbackGeneration | null>(null);
const [isReleasing, setIsReleasing] = useState(false);
const handleRelease = async () => {
if (!confirmRelease) return;
setIsReleasing(true);
const loadingId = toast.loading(`Releasing rollback protection for ${confirmRelease.shortId}...`);
try {
const res = await apiFetch(`/system/rollback/generations/${confirmRelease.id}/release`, { method: 'POST' });
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.error || 'Failed to release rollback protection');
}
toast.success(data?.message || 'Rollback protection released');
await onReleased();
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || 'Failed to release rollback protection'));
} finally {
toast.dismiss(loadingId);
setIsReleasing(false);
setConfirmRelease(null);
}
};
return (
<>
<p className="mb-3 text-sm leading-relaxed text-stat-subtitle">
Rollback-protected images from full-stack updates. Each generation is kept so a failed update can be
automatically rolled back, and clears on its own once it is superseded and its retention window
passes (configurable under Settings Infrastructure Stacks Deploy Guardrails).
</p>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="h-[62vh] max-md:h-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Stack</TableHead>
<TableHead>Generation</TableHead>
<TableHead>State</TableHead>
<TableHead>Retention</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={5} /> : (
<TableBody>
{generations.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
No rollback-protected generations on this node.
</TableCell>
</TableRow>
) : generations.map((gen, i) => (
<TableRow
key={gen.id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell className="font-medium">
<button
type="button"
disabled={nodeId === undefined}
className="hover:underline underline-offset-2 disabled:no-underline disabled:cursor-default"
onClick={() => nodeId !== undefined && window.dispatchEvent(
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId, stackName: gen.stackName } }),
)}
>
{gen.stackName}
</button>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{gen.shortId}</TableCell>
<TableCell><StateBadge gen={gen} /></TableCell>
<TableCell className="text-xs text-stat-subtitle">{formatExpiry(gen)}</TableCell>
<TableCell className="text-right">
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive transition-colors"
disabled={!gen.releasable}
onClick={() => setConfirmRelease(gen)}
aria-label={`Release rollback protection for ${gen.shortId}`}
>
<Unlock className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>
{gen.releasable
? 'Release rollback protection'
: 'Not releasable right now (mid-recovery or observing a health gate)'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
<ConfirmModal
open={!!confirmRelease}
onOpenChange={(open) => !open && setConfirmRelease(null)}
variant="destructive"
kicker="ROLLBACK · RELEASE · IRREVERSIBLE"
title={`Release rollback protection for ${confirmRelease?.stackName ?? ''}`}
confirmLabel={isReleasing ? 'Releasing...' : 'Release'}
confirming={isReleasing}
onConfirm={handleRelease}
>
<p className="text-sm text-stat-subtitle">
{confirmRelease?.isCurrent ? (
<>
This is <span className="font-medium text-stat-value">{confirmRelease?.stackName}</span>'s
current rollback point. Releasing it now means Sencho will not be able to automatically
roll this stack back until its next successful full-stack update.
</>
) : (
<>
Permanently removes the held rollback image for generation{' '}
<span className="font-mono font-medium text-stat-value">{confirmRelease?.shortId}</span>{' '}
ahead of its normal retention window.
</>
)}
</p>
</ConfirmModal>
</>
);
}
@@ -0,0 +1,20 @@
import { TableBody, TableRow, TableCell } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
/** Shared loading placeholder for the Resources page's tabbed tables (Images, Volumes, Rollback). */
export function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
return (
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="animate-in fade-in-0" style={{ animationDelay: `${r * 40}ms` }}>
{Array.from({ length: cols }).map((_, c) => (
<TableCell key={c}>
<Skeleton className={cn('h-4', c === 0 ? 'w-24' : c === 1 ? 'w-48' : 'w-16')} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RollbackGenerationsTab, type RollbackGeneration } from '../RollbackGenerationsTab';
import { toast } from '@/components/ui/toast-store';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(() => 'toast-id'),
dismiss: vi.fn(),
},
}));
function generation(overrides: Partial<RollbackGeneration> = {}): RollbackGeneration {
return {
id: 'gen-1',
shortId: 'abc123456789',
stackName: 'seerr',
status: 'superseded',
isCurrent: false,
phase: 'immediate_verified',
createdAt: Date.now(),
artifactExpiresAt: Date.now() + 3 * 24 * 60 * 60 * 1000,
releasable: true,
...overrides,
};
}
beforeEach(() => {
apiFetch.mockReset();
(toast.success as ReturnType<typeof vi.fn>).mockReset();
(toast.error as ReturnType<typeof vi.fn>).mockReset();
});
describe('RollbackGenerationsTab', () => {
it('shows superseded-generation confirm copy (not the current-generation warning) for a non-current release', async () => {
const onReleased = vi.fn();
render(<RollbackGenerationsTab generations={[generation({ isCurrent: false })]} isLoading={false} isAdmin onReleased={onReleased} />);
await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i }));
expect(await screen.findByText(/Permanently removes the held rollback image/i)).toBeInTheDocument();
expect(screen.queryByText(/Automatic rollback is unavailable until/i)).not.toBeInTheDocument();
});
it('shows the current-generation warning copy when releasing the current generation', async () => {
const onReleased = vi.fn();
render(<RollbackGenerationsTab generations={[generation({ isCurrent: true, status: 'active' })]} isLoading={false} isAdmin onReleased={onReleased} />);
await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i }));
expect(await screen.findByText(/Sencho will not be able to automatically/i)).toBeInTheDocument();
});
it('confirming release POSTs to the release endpoint and calls onReleased on success', async () => {
apiFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true, message: 'Rollback protection released', artifactsCleaned: true }) });
const onReleased = vi.fn();
render(<RollbackGenerationsTab generations={[generation()]} isLoading={false} isAdmin onReleased={onReleased} />);
await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i }));
await userEvent.click(await screen.findByRole('button', { name: 'Release' }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledWith('/system/rollback/generations/gen-1/release', { method: 'POST' }));
await waitFor(() => expect(onReleased).toHaveBeenCalled());
expect(toast.success).toHaveBeenCalledWith('Rollback protection released');
});
it('surfaces the backend partial-cleanup message distinctly from a full release', async () => {
apiFetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, message: 'Rollback protection released; cleanup will finish shortly', artifactsCleaned: false }),
});
const onReleased = vi.fn();
render(<RollbackGenerationsTab generations={[generation()]} isLoading={false} isAdmin onReleased={onReleased} />);
await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i }));
await userEvent.click(await screen.findByRole('button', { name: 'Release' }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Rollback protection released; cleanup will finish shortly'));
});
it('surfaces the server error via toast and closes the modal without a lingering Releasing state on failure', async () => {
apiFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'This rollback generation cannot be released right now (it may be observing a health gate, mid-recovery, or already in progress).', code: 'NOT_ELIGIBLE' }) });
const onReleased = vi.fn();
render(<RollbackGenerationsTab generations={[generation()]} isLoading={false} isAdmin onReleased={onReleased} />);
await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i }));
await userEvent.click(await screen.findByRole('button', { name: 'Release' }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('cannot be released right now')));
expect(onReleased).not.toHaveBeenCalled();
// Modal closes (confirm button no longer present) rather than staying stuck mid-action.
await waitFor(() => expect(screen.queryByRole('button', { name: 'Release' })).not.toBeInTheDocument());
});
it('hides the Release action for a non-admin', () => {
render(<RollbackGenerationsTab generations={[generation()]} isLoading={false} isAdmin={false} onReleased={vi.fn()} />);
expect(screen.queryByRole('button', { name: /release rollback protection/i })).not.toBeInTheDocument();
});
it('disables the Release action when the generation is not releasable', () => {
render(<RollbackGenerationsTab generations={[generation({ releasable: false })]} isLoading={false} isAdmin onReleased={vi.fn()} />);
expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeDisabled();
});
it('renders an empty state when there are no generations', () => {
render(<RollbackGenerationsTab generations={[]} isLoading={false} isAdmin onReleased={vi.fn()} />);
expect(screen.getByText(/No rollback-protected generations on this node/i)).toBeInTheDocument();
});
it('shows a loading skeleton instead of the empty state while the initial fetch is in flight', () => {
render(<RollbackGenerationsTab generations={[]} isLoading={true} isAdmin onReleased={vi.fn()} />);
expect(screen.queryByText(/No rollback-protected generations on this node/i)).not.toBeInTheDocument();
});
});
@@ -32,11 +32,13 @@ interface StacksSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required' | 'auto_create_missing_external_networks'>;
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'recovery_retention_days' | 'recovery_max_generations' | 'env_block_deploy_on_missing_required' | 'auto_create_missing_external_networks'>;
const DEFAULT_GUARDRAILS: GuardrailFields = {
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
recovery_retention_days: DEFAULT_SETTINGS.recovery_retention_days,
recovery_max_generations: DEFAULT_SETTINGS.recovery_max_generations,
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
auto_create_missing_external_networks: DEFAULT_SETTINGS.auto_create_missing_external_networks,
};
@@ -92,6 +94,8 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
const safe: GuardrailFields = {
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
recovery_retention_days: nodeData.recovery_retention_days ?? DEFAULT_SETTINGS.recovery_retention_days,
recovery_max_generations: nodeData.recovery_max_generations ?? DEFAULT_SETTINGS.recovery_max_generations,
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks,
};
@@ -219,6 +223,30 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
max={600}
/>
</SettingsField>
<SettingsField
label="Superseded rollback retention"
helper="Days an older rollback generation is retained after a newer update supersedes it, before its held image is cleaned up automatically. The current generation stays protected until it is superseded or manually released from Resources → Rollback. Default 7 days."
>
<NumberChip
value={settings.recovery_retention_days || '7'}
onChange={(v) => onGuardrailChange('recovery_retention_days', v)}
suffix="d"
min={1}
max={90}
/>
</SettingsField>
<SettingsField
label="Maximum retained rollback generations per stack"
helper="Caps how many rollback generations a stack keeps at once, current generation included (so 1 keeps only the current, 2 keeps the current plus one superseded). The oldest superseded generations beyond the cap are cleaned up early, ahead of the retention window above. 0 = unlimited (retention window only)."
>
<NumberChip
value={settings.recovery_max_generations || '0'}
onChange={(v) => onGuardrailChange('recovery_max_generations', v)}
suffix="generations"
min={0}
max={50}
/>
</SettingsField>
<SettingsField
label="Block deploy on missing required env vars"
helper="When on, a deploy or update is refused before it starts if a required ${VAR:?message} variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. Off by default."
@@ -122,6 +122,8 @@ describe('split section save payloads', () => {
'env_block_deploy_on_missing_required',
'health_gate_enabled',
'health_gate_window_seconds',
'recovery_max_generations',
'recovery_retention_days',
]);
});
+1 -1
View File
@@ -131,7 +131,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'infrastructure',
label: 'Stacks',
description: 'Stack editor, lifecycle workflow preferences, and deploy guardrails.',
keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow', 'rollback', 'retention', 'generation'],
tier: null,
scope: 'node',
},
@@ -19,6 +19,8 @@ export interface PatchableSettings {
snapshot_documentation?: '0' | '1';
health_gate_enabled?: '0' | '1';
health_gate_window_seconds?: string;
recovery_retention_days?: string;
recovery_max_generations?: string;
env_block_deploy_on_missing_required?: '0' | '1';
auto_create_missing_external_networks?: '0' | '1';
image_update_sidebar_indicators?: '0' | '1';
@@ -47,6 +49,8 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
snapshot_documentation: '0',
health_gate_enabled: '1',
health_gate_window_seconds: '90',
recovery_retention_days: '7',
recovery_max_generations: '0',
env_block_deploy_on_missing_required: '0',
auto_create_missing_external_networks: '0',
image_update_sidebar_indicators: '1',
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine, Unlock,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -49,6 +49,7 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
update_started: ArrowUp,
health_gate_passed: HeartPulse,
health_gate_failed: HeartCrack,
rollback_generation_released: Unlock,
};
const DAY_MS = 86_400_000;
@@ -17,6 +17,7 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
update_started: 'Update started',
health_gate_passed: 'Health gate passed',
health_gate_failed: 'Health gate failed',
rollback_generation_released: 'Rollback protection released',
node_update_available: 'Node update',
system: 'System',
};