mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user