feat(resources): add search, filters, and sort to Rollback generations (#1788)

Collapsible search, All/Current/Superseded filter pills matching Images/Volumes, sortable columns defaulting to newest-first, and move the long help copy behind an info tooltip.
This commit is contained in:
Anso
2026-08-09 20:45:29 -04:00
committed by GitHub
parent e9901aa24b
commit 9798700401
5 changed files with 355 additions and 22 deletions
+5 -1
View File
@@ -107,7 +107,11 @@ Before a full-stack update runs, Sencho captures the running image of every serv
Each capture is one **rollback generation**. The generation currently backing a stack's live deployment is retained for as long as it is current; once a newer update supersedes it, it is retained for a configurable window before Sencho cleans it up automatically. A stack updated repeatedly in a short span can have more than one superseded generation in that window at once.
**Resources → Rollback** lists every generation on the node: the stack it belongs to, a short generation id, whether it is the current protection or a superseded one awaiting cleanup, and roughly when it clears. An admin can release a generation's protection early from that list, including the current one, which immediately frees its image but means Sencho cannot automatically roll that stack back until its next successful full-stack update; the confirmation dialog says so before you proceed. A generation that is mid-recovery or still being observed by a health gate cannot be released until that finishes.
**Resources → Rollback** lists every generation on the node: the stack it belongs to, a short generation id, whether it is the current protection or a superseded one awaiting cleanup, and roughly when it clears. Search by stack name or generation id, filter to Current or Superseded, and click a column header to sort. An admin can release a generation's protection early from that list, including the current one, which immediately frees its image but means Sencho cannot automatically roll that stack back until its next successful full-stack update; the confirmation dialog says so before you proceed. A generation that is mid-recovery or still being observed by a health gate cannot be released until that finishes.
<Frame>
<img src="/images/health-gated-updates/rollback-generations.png" alt="Resources Rollback tab showing search, All Current and Superseded filter buttons, an info icon for help, and sortable Stack, Generation, State, and Retention column headers above the generations table" />
</Frame>
Because these images are deliberately held, deleting one directly (by id, including through the API) is refused. Release the generation from **Resources → Rollback** instead, or leave it to clear on its own.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -508,7 +508,8 @@ describe('ResourcesView', () => {
expect(await screen.findByText('seerr')).toBeInTheDocument();
expect(screen.getByText('abc123456789')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
// State badge and the Current filter pill both render this label.
expect(screen.getAllByText('Current').length).toBeGreaterThanOrEqual(2);
expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeInTheDocument();
});
});
@@ -1,11 +1,14 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { useTableSort } from '@/hooks/useTableSort';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 { Info, Search, 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';
@@ -33,6 +36,21 @@ interface RollbackGenerationsTabProps {
onReleased: () => void | Promise<void>;
}
/** Displayed State badge values; used by both StateBadge and the State comparator. */
type DisplayedRollbackState = 'current' | 'recovery_required' | 'superseded';
function getDisplayedState(gen: RollbackGeneration): DisplayedRollbackState {
if (gen.status === 'recovery_required') return 'recovery_required';
if (gen.isCurrent) return 'current';
return 'superseded';
}
const DISPLAYED_STATE_ORDER: Record<DisplayedRollbackState, number> = {
current: 0,
recovery_required: 1,
superseded: 2,
};
function formatExpiry(gen: RollbackGeneration): string {
if (gen.isCurrent) return 'Protected while current';
if (gen.status === 'recovery_required') return 'Recovery required';
@@ -44,23 +62,98 @@ function formatExpiry(gen: RollbackGeneration): string {
}
function StateBadge({ gen }: { gen: RollbackGeneration }) {
switch (gen.status) {
const displayed = getDisplayedState(gen);
switch (displayed) {
case 'recovery_required':
return <Badge variant="destructive" className="text-[10px] h-5">Recovery required</Badge>;
case 'current':
return <Badge variant="default" className="text-[10px] h-5">Current</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;
const unhandled: never = displayed;
return <Badge variant="secondary" className="text-[10px] h-5">{String(unhandled)}</Badge>;
}
}
}
function tieBreak(a: RollbackGeneration, b: RollbackGeneration): number {
// Newer first, then stable id ascending.
if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
return a.id.localeCompare(b.id);
}
type RollbackSortKey = 'stack' | 'generation' | 'state' | 'retention';
// Stable comparator map (module scope so useTableSort does not re-sort every
// render). Primary keys fall through to createdAt desc then id asc on ties.
const ROLLBACK_COMPARATORS: Record<RollbackSortKey, (a: RollbackGeneration, b: RollbackGeneration) => number> = {
stack: (a, b) => {
const byName = a.stackName.localeCompare(b.stackName);
return byName !== 0 ? byName : tieBreak(a, b);
},
generation: (a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
return a.id.localeCompare(b.id);
},
state: (a, b) => {
const byState = DISPLAYED_STATE_ORDER[getDisplayedState(a)] - DISPLAYED_STATE_ORDER[getDisplayedState(b)];
return byState !== 0 ? byState : tieBreak(a, b);
},
// Asc: finite expiries ascending, then rows without a finite expiry.
// Desc is the exact reverse via useTableSort's direction multiplier.
retention: (a, b) => {
const aExp = a.artifactExpiresAt;
const bExp = b.artifactExpiresAt;
if (aExp === null && bExp === null) return tieBreak(a, b);
if (aExp === null) return 1;
if (bExp === null) return -1;
if (aExp !== bExp) return aExp - bExp;
return tieBreak(a, b);
},
};
const SORT_HEAD_MOBILE = 'max-md:[&_button]:min-h-11 max-md:[&_button]:py-2';
const ROLLBACK_HELP =
'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).';
type RollbackStateFilter = 'all' | 'current' | 'superseded';
const FILTER_OPTIONS: { key: RollbackStateFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'current', label: 'Current' },
{ key: 'superseded', label: 'Superseded' },
];
function FilterToggle({
value,
onChange,
counts,
}: {
value: RollbackStateFilter;
onChange: (v: RollbackStateFilter) => void;
counts: Record<RollbackStateFilter, number>;
}) {
return (
<div className="flex items-center gap-1">
{FILTER_OPTIONS.map(({ key, label }) => (
<Button
key={key}
type="button"
variant={value === key ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5 gap-1.5 max-md:min-h-11"
onClick={() => onChange(key)}
>
{label}
<span className="font-mono tabular-nums text-[10px] opacity-70">{counts[key]}</span>
</Button>
))}
</div>
);
}
/**
* Full-stack rollback generations (the sencho-rb/<id>/<service>:hold images
* StackUpdateRecoveryService creates). Kept in its own tab rather than the
@@ -70,6 +163,35 @@ function StateBadge({ gen }: { gen: RollbackGeneration }) {
export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId, onReleased }: RollbackGenerationsTabProps) {
const [confirmRelease, setConfirmRelease] = useState<RollbackGeneration | null>(null);
const [isReleasing, setIsReleasing] = useState(false);
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const [stateFilter, setStateFilter] = useState<RollbackStateFilter>('all');
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (searchExpanded) searchRef.current?.focus(); }, [searchExpanded]);
const filterCounts: Record<RollbackStateFilter, number> = {
all: generations.length,
current: 0,
superseded: 0,
};
for (const gen of generations) {
// Recovery-required rows count under Current so mid-recovery generations
// stay visible when that filter is active (badge still says Recovery required).
if (getDisplayedState(gen) === 'superseded') filterCounts.superseded++;
else filterCounts.current++;
}
const searchQuery = search.toLowerCase();
const filtered = generations.filter((gen) => {
const displayed = getDisplayedState(gen);
if (stateFilter === 'superseded' && displayed !== 'superseded') return false;
if (stateFilter === 'current' && displayed === 'superseded') return false;
if (searchQuery === '') return true;
return gen.stackName.toLowerCase().includes(searchQuery)
|| gen.shortId.toLowerCase().includes(searchQuery);
});
// Default Generation descending preserves the API's newest-first order.
const { sorted, sortKey, sortDir, toggleSort } = useTableSort(filtered, ROLLBACK_COMPARATORS, 'generation', 'desc');
const handleRelease = async () => {
if (!confirmRelease) return;
@@ -95,20 +217,71 @@ export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId
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="flex flex-wrap items-center gap-2 mb-4">
{search !== '' || searchExpanded ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchRef}
placeholder="Search stack or generation..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => { if (search === '') setSearchExpanded(false); }}
className="pl-9 h-9 max-md:min-h-11"
aria-label="Search rollback generations"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0 shrink-0 max-md:min-h-11 max-md:min-w-11"
onClick={() => setSearchExpanded(true)}
aria-label="Search rollback generations"
>
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search rollback generations</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={stateFilter}
onChange={setStateFilter}
counts={filterCounts}
/>
<div className="flex-1" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 p-0 shrink-0 text-muted-foreground hover:text-foreground max-md:min-h-11 max-md:min-w-11"
aria-label="About rollback generations"
>
<Info className="w-4 h-4" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-sm">
<p className="text-sm leading-relaxed">{ROLLBACK_HELP}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<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 className="text-center">State</TableHead>
<TableHead>Retention</TableHead>
<SortableTableHead label="Stack" columnKey="stack" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className={SORT_HEAD_MOBILE} />
<SortableTableHead label="Generation" columnKey="generation" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className={SORT_HEAD_MOBILE} />
<SortableTableHead label="State" columnKey="state" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className={`text-center ${SORT_HEAD_MOBILE}`} />
<SortableTableHead label="Retention" columnKey="retention" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className={SORT_HEAD_MOBILE} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -120,7 +293,13 @@ export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId
No rollback-protected generations on this node.
</TableCell>
</TableRow>
) : generations.map((gen, i) => (
) : sorted.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
No generations match this filter.
</TableCell>
</TableRow>
) : sorted.map((gen, i) => (
<TableRow
key={gen.id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RollbackGenerationsTab, type RollbackGeneration } from '../RollbackGenerationsTab';
import { toast } from '@/components/ui/toast-store';
@@ -31,6 +31,27 @@ function generation(overrides: Partial<RollbackGeneration> = {}): RollbackGenera
};
}
function stackNamesInOrder(): string[] {
return screen.getAllByRole('row').slice(1).map((row) => {
const cells = within(row).getAllByRole('cell');
return cells[0]?.textContent ?? '';
});
}
function shortIdsInOrder(): string[] {
return screen.getAllByRole('row').slice(1).map((row) => {
const cells = within(row).getAllByRole('cell');
return cells[1]?.textContent ?? '';
});
}
function stateLabelsInOrder(): string[] {
return screen.getAllByRole('row').slice(1).map((row) => {
const cells = within(row).getAllByRole('cell');
return cells[2]?.textContent ?? '';
});
}
beforeEach(() => {
apiFetch.mockReset();
(toast.success as ReturnType<typeof vi.fn>).mockReset();
@@ -117,4 +138,132 @@ describe('RollbackGenerationsTab', () => {
render(<RollbackGenerationsTab generations={[]} isLoading={true} isAdmin onReleased={vi.fn()} />);
expect(screen.queryByText(/No rollback-protected generations on this node/i)).not.toBeInTheDocument();
});
describe('search and sort', () => {
const newestFirst = [
generation({ id: 'gen-new', shortId: 'newaaaaaaaaa', stackName: 'Zebra', status: 'active', isCurrent: true, createdAt: 3000, artifactExpiresAt: null }),
generation({ id: 'gen-mid', shortId: 'midbbbbbbbbb', stackName: 'alpha', status: 'recovery_required', isCurrent: true, createdAt: 2000, artifactExpiresAt: null }),
generation({ id: 'gen-old', shortId: 'oldccccccccc', stackName: 'bravo', status: 'superseded', isCurrent: false, createdAt: 1000, artifactExpiresAt: 5000 }),
];
it('defaults to newest-first generation order', () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
expect(shortIdsInOrder()).toEqual(['newaaaaaaaaa', 'midbbbbbbbbb', 'oldccccccccc']);
});
it('reverses to oldest-first when Generation is clicked', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /^Generation/i }));
expect(shortIdsInOrder()).toEqual(['oldccccccccc', 'midbbbbbbbbb', 'newaaaaaaaaa']);
});
it('sorts Stack ascending then descending', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /^Stack/i }));
expect(stackNamesInOrder()).toEqual(['alpha', 'bravo', 'Zebra']);
await userEvent.click(screen.getByRole('button', { name: /^Stack/i }));
expect(stackNamesInOrder()).toEqual(['Zebra', 'bravo', 'alpha']);
});
it('sorts State by displayed values including current recovery_required', async () => {
const rows = [
generation({ id: 'g-sup', shortId: 'sup111111111', stackName: 's1', status: 'superseded', isCurrent: false, createdAt: 1, artifactExpiresAt: 100 }),
generation({ id: 'g-rec', shortId: 'rec222222222', stackName: 's2', status: 'recovery_required', isCurrent: true, createdAt: 2, artifactExpiresAt: null }),
generation({ id: 'g-cur', shortId: 'cur333333333', stackName: 's3', status: 'active', isCurrent: true, createdAt: 3, artifactExpiresAt: null }),
];
render(<RollbackGenerationsTab generations={rows} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /^State/i }));
expect(stateLabelsInOrder()).toEqual(['Current', 'Recovery required', 'Superseded']);
await userEvent.click(screen.getByRole('button', { name: /^State/i }));
expect(stateLabelsInOrder()).toEqual(['Superseded', 'Recovery required', 'Current']);
});
it('sorts Retention with dated expiries before undated on ascending, reverse on descending', async () => {
const rows = [
generation({ id: 'g-null', shortId: 'nul111111111', stackName: 'n', status: 'active', isCurrent: true, createdAt: 3, artifactExpiresAt: null }),
generation({ id: 'g-late', shortId: 'lat222222222', stackName: 'l', status: 'superseded', isCurrent: false, createdAt: 2, artifactExpiresAt: 9000 }),
generation({ id: 'g-early', shortId: 'ear333333333', stackName: 'e', status: 'superseded', isCurrent: false, createdAt: 1, artifactExpiresAt: 1000 }),
];
render(<RollbackGenerationsTab generations={rows} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /^Retention/i }));
expect(shortIdsInOrder()).toEqual(['ear333333333', 'lat222222222', 'nul111111111']);
await userEvent.click(screen.getByRole('button', { name: /^Retention/i }));
expect(shortIdsInOrder()).toEqual(['nul111111111', 'lat222222222', 'ear333333333']);
});
it('filters by case-insensitive stack name', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /search rollback generations/i }));
await userEvent.type(screen.getByRole('textbox', { name: /search rollback generations/i }), 'ALPHA');
expect(stackNamesInOrder()).toEqual(['alpha']);
});
it('filters by short generation id', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /search rollback generations/i }));
await userEvent.type(screen.getByRole('textbox', { name: /search rollback generations/i }), 'oldccc');
expect(shortIdsInOrder()).toEqual(['oldccccccccc']);
});
it('expands search on click, keeps input open with a query, and collapses on empty blur', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
const expand = screen.getByRole('button', { name: /search rollback generations/i });
expect(expand.className).toMatch(/max-md:min-h-11/);
expect(expand.className).toMatch(/max-md:min-w-11/);
await userEvent.click(expand);
const input = screen.getByRole('textbox', { name: /search rollback generations/i });
expect(input).toHaveFocus();
expect(input.className).toMatch(/max-md:min-h-11/);
await userEvent.type(input, 'z');
await userEvent.tab();
expect(screen.getByRole('textbox', { name: /search rollback generations/i })).toBeInTheDocument();
await userEvent.clear(screen.getByRole('textbox', { name: /search rollback generations/i }));
await userEvent.tab();
expect(screen.queryByRole('textbox', { name: /search rollback generations/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /search rollback generations/i })).toBeInTheDocument();
});
it('shows filtered-empty copy when search matches nothing', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /search rollback generations/i }));
await userEvent.type(screen.getByRole('textbox', { name: /search rollback generations/i }), 'no-such-stack');
expect(screen.getByText(/No generations match this filter/i)).toBeInTheDocument();
expect(screen.queryByText(/No rollback-protected generations on this node/i)).not.toBeInTheDocument();
});
it('filters Current (including recovery_required) and Superseded by displayed state', async () => {
const rows = [
generation({ id: 'g-cur', shortId: 'cur111111111', stackName: 'curr', status: 'active', isCurrent: true, createdAt: 3 }),
generation({ id: 'g-rec', shortId: 'rec222222222', stackName: 'recv', status: 'recovery_required', isCurrent: true, createdAt: 2, artifactExpiresAt: null }),
generation({ id: 'g-sup', shortId: 'sup333333333', stackName: 'supe', status: 'superseded', isCurrent: false, createdAt: 1 }),
];
render(<RollbackGenerationsTab generations={rows} isLoading={false} isAdmin onReleased={vi.fn()} />);
expect(screen.getByRole('button', { name: /^All/i })).toHaveTextContent('3');
expect(screen.getByRole('button', { name: /^Current/i })).toHaveTextContent('2');
expect(screen.getByRole('button', { name: /^Superseded/i })).toHaveTextContent('1');
await userEvent.click(screen.getByRole('button', { name: /^Current/i }));
expect(stackNamesInOrder()).toEqual(['curr', 'recv']);
await userEvent.click(screen.getByRole('button', { name: /^Superseded/i }));
expect(stackNamesInOrder()).toEqual(['supe']);
await userEvent.click(screen.getByRole('button', { name: /^All/i }));
expect(stackNamesInOrder()).toEqual(['curr', 'recv', 'supe']);
});
it('applies mobile touch-target classes on sortable header wrappers', () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
const stackHead = screen.getByRole('button', { name: /^Stack/i }).closest('th');
expect(stackHead?.className).toMatch(/max-md:\[&_button\]:min-h-11/);
});
it('puts the help copy behind an info tooltip instead of a body paragraph', async () => {
render(<RollbackGenerationsTab generations={newestFirst} isLoading={false} isAdmin onReleased={vi.fn()} />);
expect(screen.queryByText(/Rollback-protected images from full-stack updates/i)).not.toBeInTheDocument();
await userEvent.hover(screen.getByRole('button', { name: /about rollback generations/i }));
expect(await screen.findByText(/Rollback-protected images from full-stack updates/i)).toBeInTheDocument();
expect(screen.getByText(/Deploy Guardrails/i)).toBeInTheDocument();
});
});
});