mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user