mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
feat: add Docker label audit across Fleet and Stack views (#1531)
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment } from 'react';
|
||||
import { ChevronDown, ChevronRight, ExternalLink, Lock, RefreshCw, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
LABEL_DISAMBIGUATION_COPY,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_FACET_LABELS,
|
||||
matchesSearch,
|
||||
sourcesPresent,
|
||||
type ContainerLabelRow,
|
||||
type FleetLabelInventoryResponse,
|
||||
type LabelIndexRow,
|
||||
type LabelSource,
|
||||
type LabelValue,
|
||||
} from '@/lib/labelInventory';
|
||||
|
||||
type ViewMode = 'container' | 'label';
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
|
||||
interface ContainerLabelsTabProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
}
|
||||
|
||||
function LabelValueCell({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
|
||||
const { isAdmin } = useAuth();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/90">
|
||||
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
|
||||
<span>{label.value}</span>
|
||||
{label.redacted && isAdmin && onReveal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-brand hover:underline"
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerLabelsTab({ onNavigateToNode }: ContainerLabelsTabProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('container');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revealSecrets, setRevealSecrets] = useState(false);
|
||||
const [data, setData] = useState<FleetLabelInventoryResponse | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
|
||||
|
||||
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = reveal ? '?reveal=1' : '';
|
||||
const res = await apiFetch(`/fleet/container-labels${qs}`, { localOnly: true });
|
||||
if (!res.ok) throw new Error('Failed to load Docker label audit');
|
||||
setData(await res.json() as FleetLabelInventoryResponse);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load Docker label audit';
|
||||
toast.error(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [revealSecrets]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchInventory();
|
||||
}, [fetchInventory]);
|
||||
|
||||
const allContainers = useMemo(() => {
|
||||
if (!data) return [] as Array<ContainerLabelRow & { nodeId: number; nodeName: string }>;
|
||||
const rows: Array<ContainerLabelRow & { nodeId: number; nodeName: string }> = [];
|
||||
for (const node of data.nodes) {
|
||||
if (node.status !== 'ok' || !node.inventory) continue;
|
||||
for (const c of node.inventory.containers) {
|
||||
rows.push({ ...c, nodeId: node.nodeId, nodeName: node.nodeName });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [data]);
|
||||
|
||||
const filteredContainers = useMemo(() => {
|
||||
return allContainers.filter(c =>
|
||||
matchesSearch(search, c.name, c.stack, c.service, c.state, c.nodeName)
|
||||
|| c.labels.some(l => matchesSearch(search, l.key, l.value)),
|
||||
);
|
||||
}, [allContainers, search]);
|
||||
|
||||
// Sources present across the aggregated index, for the data-driven facet row. Derived from
|
||||
// the unfiltered data so toggling a facet off never removes the facet itself.
|
||||
const labelSourceFacets = useMemo(
|
||||
() => sourcesPresent((data?.aggregatedByLabel ?? []).map(r => r.source)),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredByLabel = useMemo(() => {
|
||||
const source = data?.aggregatedByLabel ?? [];
|
||||
return source.filter(row =>
|
||||
!excludedSources.has(row.source)
|
||||
&& (matchesSearch(search, row.key, row.value)
|
||||
|| row.containers.some(c => matchesSearch(search, c.name, c.stack, c.nodeName))),
|
||||
);
|
||||
}, [data, search, excludedSources]);
|
||||
|
||||
const toggleSource = (s: LabelSource) => {
|
||||
setExcludedSources(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Nodes that were unreachable (nodeErrors) or whose inventory came back partial (some
|
||||
// containers or images could not be inspected). Named so the warning is truthful.
|
||||
const degradedNodes = useMemo(() => {
|
||||
const unreachable: string[] = [];
|
||||
const partial: string[] = [];
|
||||
if (data) {
|
||||
const nameById = new Map(data.nodes.map(n => [n.nodeId, n.nodeName] as const));
|
||||
for (const idStr of Object.keys(data.nodeErrors)) {
|
||||
unreachable.push(nameById.get(Number(idStr)) ?? `node ${idStr}`);
|
||||
}
|
||||
for (const n of data.nodes) {
|
||||
if (n.status === 'ok' && n.inventory?.partial) partial.push(n.nodeName);
|
||||
}
|
||||
}
|
||||
return { unreachable, partial };
|
||||
}, [data]);
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReveal = () => {
|
||||
setRevealSecrets(true);
|
||||
void fetchInventory(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="container-labels-tab">
|
||||
<p className="text-xs text-stat-subtitle leading-relaxed max-w-3xl">{LABEL_DISAMBIGUATION_COPY}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Docker label audit</p>
|
||||
<SegmentedControl
|
||||
className="mt-2"
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as ViewMode)}
|
||||
options={[
|
||||
{ value: 'container', label: 'By container' },
|
||||
{ value: 'label', label: 'By label' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter labels, containers, stacks..."
|
||||
className="h-9 w-56 pl-8 max-md:w-full"
|
||||
/>
|
||||
</div>
|
||||
{viewMode === 'label' && labelSourceFacets.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={excludedSources.size > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
{excludedSources.size > 0 && (
|
||||
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{excludedSources.size}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Defined by</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{labelSourceFacets.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={!excludedSources.has(s) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
aria-pressed={!excludedSources.has(s)}
|
||||
onClick={() => toggleSource(s)}
|
||||
>
|
||||
{SOURCE_FACET_LABELS[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{excludedSources.size > 0 && (
|
||||
<Button variant="ghost" size="sm" className="w-full h-8 text-xs" onClick={() => setExcludedSources(new Set())}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-9 w-9 p-0" onClick={() => void fetchInventory()} disabled={loading} aria-label="Refresh">
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<p className="text-sm text-stat-subtitle">Loading Docker label audit...</p>
|
||||
)}
|
||||
|
||||
{!loading && data && (degradedNodes.unreachable.length > 0 || degradedNodes.partial.length > 0) && (
|
||||
<p className="text-xs text-warning">
|
||||
{degradedNodes.unreachable.length > 0 && `Could not reach ${degradedNodes.unreachable.join(', ')}. `}
|
||||
{degradedNodes.partial.length > 0 && `Some containers or images could not be inspected on ${degradedNodes.partial.join(', ')}. `}
|
||||
Showing partial fleet data.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{viewMode === 'container' && (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Container</TableHead>
|
||||
<TableHead>Stack</TableHead>
|
||||
<TableHead>Node</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead className="text-right">Labels</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredContainers.map((c) => {
|
||||
const rowKey = `${c.nodeId}:${c.id}`;
|
||||
const isOpen = expanded.has(rowKey);
|
||||
return (
|
||||
<Fragment key={rowKey}>
|
||||
<TableRow className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<button type="button" onClick={() => toggleExpanded(rowKey)} className="text-muted-foreground">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.name || c.id.slice(0, 12)}</TableCell>
|
||||
<TableCell className="text-xs">{c.stack ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">{c.nodeName}</TableCell>
|
||||
<TableCell className="text-xs">{c.state}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{c.labels.length}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{c.stack && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => onNavigateToNode(c.nodeId, c.stack!)}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" /> Open stack
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && (
|
||||
<TableRow key={`${rowKey}-detail`}>
|
||||
<TableCell colSpan={7} className="bg-card/40 p-0">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{c.labels.map((label) => (
|
||||
<TableRow key={`${rowKey}-${label.key}`}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground w-[40%]">{label.key}</TableCell>
|
||||
<TableCell>
|
||||
<LabelValueCell label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-stat-subtitle">{SOURCE_LABELS[label.source]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{c.labels.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-xs text-stat-subtitle py-3">No labels on this container.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredContainers.length === 0 && !loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-sm text-stat-subtitle py-6 text-center">No containers match this filter.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'label' && (
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
|
||||
Fleet shows container-level provenance and cannot distinguish Compose-declared labels from other container-set labels, so those appear as "Present at runtime".
|
||||
</p>
|
||||
)}
|
||||
|
||||
{viewMode === 'label' && (
|
||||
<div className="space-y-3">
|
||||
{filteredByLabel.map((row) => (
|
||||
<LabelGroupCard key={`${row.key}=${row.value}=${row.source}`} row={row} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} onNavigateToNode={onNavigateToNode} />
|
||||
))}
|
||||
{filteredByLabel.length === 0 && !loading && (
|
||||
<p className="text-sm text-stat-subtitle py-4 text-center">No labels match this filter.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
|
||||
Runtime labels are static until the container is recreated. Changes in Compose require save and redeploy.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelGroupCard({
|
||||
row,
|
||||
onReveal,
|
||||
onNavigateToNode,
|
||||
}: {
|
||||
row: LabelIndexRow;
|
||||
onReveal?: () => void;
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border bg-card/40 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="font-mono text-xs font-medium">{row.key}</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<LabelValueCell label={{ key: row.key, value: row.value, source: row.source, redacted: row.redacted }} onReveal={onReveal} />
|
||||
<Badge variant="outline" className="text-[10px]">{SOURCE_LABELS[row.source]}</Badge>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{row.containers.map((c) => (
|
||||
<li key={`${c.nodeId ?? 'local'}:${c.id}`} className="flex flex-wrap items-center gap-2 text-xs text-stat-subtitle">
|
||||
<span className="font-mono text-foreground/80">{c.name}</span>
|
||||
{c.nodeName && <Badge variant="secondary" className="text-[10px] h-5">{c.nodeName}</Badge>}
|
||||
{c.stack && (
|
||||
<>
|
||||
<span>· {c.stack}</span>
|
||||
{c.nodeId !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand hover:underline font-mono text-[10px] uppercase"
|
||||
onClick={() => onNavigateToNode(c.nodeId!, c.stack!)}
|
||||
>
|
||||
Open stack
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ContainerLabelsTab } from '../ContainerLabelsTab';
|
||||
import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
const mockFleet = {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: 1,
|
||||
nodeName: 'Local',
|
||||
status: 'ok' as const,
|
||||
error: null,
|
||||
inventory: {
|
||||
nodeId: 1,
|
||||
partial: false,
|
||||
generatedAt: Date.now(),
|
||||
containers: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'web-1',
|
||||
stack: 'demo',
|
||||
service: 'web',
|
||||
state: 'running',
|
||||
labels: [{ key: 'traefik.enable', value: 'true', source: 'runtime' as const }],
|
||||
},
|
||||
],
|
||||
byLabel: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
aggregatedByLabel: [
|
||||
{
|
||||
key: 'traefik.enable',
|
||||
value: 'true',
|
||||
source: 'runtime' as const,
|
||||
containers: [{ id: 'c1', name: 'web-1', stack: 'demo', service: 'web', nodeId: 1, nodeName: 'Local' }],
|
||||
},
|
||||
],
|
||||
nodeErrors: {},
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Two rows sharing the same key=value but distinct sources (image vs runtime).
|
||||
const dupMock = {
|
||||
nodes: [{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: false, generatedAt: Date.now(), containers: [], byLabel: [] } }],
|
||||
aggregatedByLabel: [
|
||||
{ key: 'dup.label', value: 'v', source: 'image' as const, containers: [{ id: 'c1', name: 'a-1', stack: 's', service: 'a', nodeId: 1, nodeName: 'Local' }] },
|
||||
{ key: 'dup.label', value: 'v', source: 'runtime' as const, containers: [{ id: 'c2', name: 'b-1', stack: 's', service: 'b', nodeId: 1, nodeName: 'Local' }] },
|
||||
],
|
||||
nodeErrors: {},
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
describe('ContainerLabelsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockFleet,
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it('renders audit sections and toggles view mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
|
||||
expect(screen.getByText('Docker label audit')).toBeInTheDocument();
|
||||
expect(await screen.findByText('web-1')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('By label'));
|
||||
expect(await screen.findByText('traefik.enable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the same key=value distinct per source with its own badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(2);
|
||||
// The misleading "External automation label" marker on non-Compose keys was removed.
|
||||
expect(screen.queryByText(/External automation label/)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters the by-label list by search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getByText('traefik.enable')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
|
||||
expect(screen.queryByText('traefik.enable')).toBeNull();
|
||||
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters the by-container list by search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText('web-1')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
|
||||
expect(screen.queryByText('web-1')).toBeNull();
|
||||
expect(screen.getByText('No containers match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes a facet in the Filters popover only for sources present, with exact labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
// mockFleet has only a runtime source.
|
||||
expect(screen.getByRole('button', { name: 'Runtime' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Image' })).toBeNull();
|
||||
});
|
||||
|
||||
it('filters the by-label list by source facet, hiding an unpressed source', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(2);
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
const imageBtn = screen.getByRole('button', { name: 'Image' });
|
||||
expect(imageBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await user.click(imageBtn);
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(1);
|
||||
// The facet stays present (just unpressed) because facets derive from unfiltered data.
|
||||
expect(screen.getByRole('button', { name: 'Image' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('shows the empty state when every source facet is turned off', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Image' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Runtime' }));
|
||||
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names unreachable and partial nodes in the warning', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: true, generatedAt: Date.now(), containers: [], byLabel: [] } },
|
||||
{ nodeId: 2, nodeName: 'Edge', status: 'error' as const, error: 'boom', inventory: null },
|
||||
],
|
||||
aggregatedByLabel: [],
|
||||
nodeErrors: { 2: 'boom' },
|
||||
generatedAt: Date.now(),
|
||||
}),
|
||||
} as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText(/Could not reach Edge/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be inspected on Local/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user