From 9798700401ef590cbbeacd1c7cefc54ab440807f Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 9 Aug 2026 20:45:29 -0400 Subject: [PATCH] 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. --- docs/features/health-gated-updates.mdx | 6 +- .../rollback-generations.png | Bin 0 -> 30708 bytes .../__tests__/ResourcesView.test.tsx | 3 +- .../resources/RollbackGenerationsTab.tsx | 217 ++++++++++++++++-- .../__tests__/RollbackGenerationsTab.test.tsx | 151 +++++++++++- 5 files changed, 355 insertions(+), 22 deletions(-) create mode 100644 docs/images/health-gated-updates/rollback-generations.png 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 0000000000000000000000000000000000000000..c01a55106d45e4f7acb5b603f622c1cd6fd80906 GIT binary patch literal 30708 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 { 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(); + }); + }); });