diff --git a/docs/features/health-gated-updates.mdx b/docs/features/health-gated-updates.mdx index cc995c04..60178268 100644 --- a/docs/features/health-gated-updates.mdx +++ b/docs/features/health-gated-updates.mdx @@ -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. + + + 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 + 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. diff --git a/docs/images/health-gated-updates/rollback-generations.png b/docs/images/health-gated-updates/rollback-generations.png new file mode 100644 index 00000000..c01a5510 Binary files /dev/null and b/docs/images/health-gated-updates/rollback-generations.png differ diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index e5f96c84..980fe545 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -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(); }); }); diff --git a/frontend/src/components/resources/RollbackGenerationsTab.tsx b/frontend/src/components/resources/RollbackGenerationsTab.tsx index 0c0a160c..3b37491f 100644 --- a/frontend/src/components/resources/RollbackGenerationsTab.tsx +++ b/frontend/src/components/resources/RollbackGenerationsTab.tsx @@ -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; } +/** 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 = { + 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 Recovery required; + case 'current': + return Current; case 'superseded': return Superseded; - case 'active': - case 'restored_current': - return gen.isCurrent - ? Current - : Superseded; default: { - const unhandled: never = gen.status; + const unhandled: never = displayed; return {String(unhandled)}; } } } +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 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; +}) { + return ( +
+ {FILTER_OPTIONS.map(({ key, label }) => ( + + ))} +
+ ); +} + /** * Full-stack rollback generations (the sencho-rb//: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(null); const [isReleasing, setIsReleasing] = useState(false); + const [search, setSearch] = useState(''); + const [searchExpanded, setSearchExpanded] = useState(false); + const [stateFilter, setStateFilter] = useState('all'); + const searchRef = useRef(null); + useEffect(() => { if (searchExpanded) searchRef.current?.focus(); }, [searchExpanded]); + + const filterCounts: Record = { + 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 ( <> -

- 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). -

+
+ {search !== '' || searchExpanded ? ( +
+ + setSearch(e.target.value)} + onBlur={() => { if (search === '') setSearchExpanded(false); }} + className="pl-9 h-9 max-md:min-h-11" + aria-label="Search rollback generations" + /> +
+ ) : ( + + + + + + Search rollback generations + + + )} + +
+ + + + + + +

{ROLLBACK_HELP}

+
+
+
+
- Stack - Generation - State - Retention + + + + Actions @@ -120,7 +293,13 @@ export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId No rollback-protected generations on this node. - ) : generations.map((gen, i) => ( + ) : sorted.length === 0 ? ( + + + No generations match this filter. + + + ) : sorted.map((gen, i) => ( = {}): 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).mockReset(); @@ -117,4 +138,132 @@ describe('RollbackGenerationsTab', () => { render(); 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(); + expect(shortIdsInOrder()).toEqual(['newaaaaaaaaa', 'midbbbbbbbbb', 'oldccccccccc']); + }); + + it('reverses to oldest-first when Generation is clicked', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^Generation/i })); + expect(shortIdsInOrder()).toEqual(['oldccccccccc', 'midbbbbbbbbb', 'newaaaaaaaaa']); + }); + + it('sorts Stack ascending then descending', async () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); + }); });