feat: add Docker label audit across Fleet and Stack views (#1531)

This commit is contained in:
Anso
2026-07-03 18:26:09 -04:00
committed by GitHub
parent 10fb93dcb1
commit 4a350e7a0a
27 changed files with 3099 additions and 1 deletions
+17 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import {
RefreshCw, Camera, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
} from 'lucide-react';
import { FleetMasthead } from './fleet/FleetMasthead';
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
import { springs } from '@/lib/motion';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
@@ -29,6 +30,7 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { ContainerLabelsTab } from './fleet/ContainerLabelsTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
@@ -49,6 +51,8 @@ interface FleetViewProps {
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
const containerLabelsEnabled = hasCapability('container-label-inventory');
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
@@ -132,6 +136,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
<Workflow className="w-4 h-4 mr-1.5" />Map
</TabsTrigger>
</TabsHighlightItem>
{containerLabelsEnabled && (
<TabsHighlightItem value="container-labels">
<TabsTrigger value="container-labels">
<Tag className="w-4 h-4 mr-1.5" />Docker Labels
</TabsTrigger>
</TabsHighlightItem>
)}
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="deployments">
@@ -245,6 +256,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
<TabsContent value="dependencies">
<DependencyMapTab />
</TabsContent>
{containerLabelsEnabled && (
<TabsContent value="container-labels">
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
</TabsContent>
)}
{isPaid && (
<TabsContent value="deployments">
<DeploymentsTab />
@@ -15,6 +15,7 @@ import DriftPanel from './stack/DriftPanel';
import PreflightPanel from './stack/PreflightPanel';
import StoragePanel from './stack/StoragePanel';
import EnvironmentPanel from './stack/EnvironmentPanel';
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
import StackNetworkingPanel from './stack/StackNetworkingPanel';
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from '@/components/dashboard/types';
@@ -101,6 +102,7 @@ export default function StackAnatomyPanel({
const networkingEnabled = hasCapability('compose-networking');
const storageEnabled = hasCapability('compose-storage');
const envInventoryEnabled = hasCapability('env-inventory');
const composeLabelsEnabled = hasCapability('container-label-inventory');
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null);
// Merged effective facts (services/ports/volumes/networks/restart) for a
@@ -370,6 +372,9 @@ export default function StackAnatomyPanel({
{envInventoryEnabled && (
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
)}
{composeLabelsEnabled && (
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
)}
{networkingEnabled && (
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
)}
@@ -623,6 +628,11 @@ export default function StackAnatomyPanel({
<EnvironmentPanel stackName={stackName} />
</TabsContent>
)}
{composeLabelsEnabled && (
<TabsContent value="compose-labels" className="flex flex-col flex-1 min-h-0 mt-0">
<ComposeLabelsPanel stackName={stackName} />
</TabsContent>
)}
{doctorEnabled && (
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
<PreflightPanel stackName={stackName} />
@@ -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();
});
});
@@ -0,0 +1,365 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Info, Lock, 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 { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAuth } from '@/context/AuthContext';
import {
LABEL_DISAMBIGUATION_COPY,
SOURCE_LABELS,
SOURCE_FACET_LABELS,
matchesSearch,
sourcesPresent,
type LabelSource,
type LabelValue,
type StackLabelInventory,
} from '@/lib/labelInventory';
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const CARD_CLASS = 'rounded-lg border border-muted px-3 py-2.5';
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
/**
* A label is visible when its source is not excluded AND either the text matches its
* key/value or the parent matched the search (a service by name, a replica by name or id).
* Parent matches bypass text matching only, never the source facets.
*/
function labelVisible(label: LabelValue, search: string, excluded: Set<LabelSource>, parentHit: boolean): boolean {
if (excluded.has(label.source)) return false;
return parentHit || matchesSearch(search, label.key, label.value);
}
function LabelRow({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
const { isAdmin } = useAuth();
return (
<div className="flex flex-wrap items-center gap-2 border-t border-muted py-2 first:border-t-0" data-testid="compose-label-row">
<span className="font-mono text-[12px] font-medium text-foreground/90">{label.key}</span>
<span className="text-muted-foreground">=</span>
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/80">
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
{label.value}
</span>
<span className="rounded border border-muted px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle">
{SOURCE_LABELS[label.source]}
</span>
{label.redacted && isAdmin && onReveal && (
<button type="button" onClick={onReveal} className="font-mono text-[10px] uppercase text-brand hover:underline">
Reveal
</button>
)}
</div>
);
}
function MismatchBadge({ kind, count }: { kind: 'only-compose' | 'only-container' | 'both' | 'changed'; count: number }) {
const labels = {
'only-compose': 'only in Compose',
'only-container': 'only on running container',
both: 'present in both',
changed: 'value changed',
};
const tones = {
'only-compose': 'border-warning/40 bg-warning/[0.06] text-warning',
'only-container': 'border-info/40 bg-info/[0.06] text-info',
both: 'border-muted bg-card/40 text-stat-subtitle',
changed: 'border-warning/40 bg-warning/[0.06] text-warning',
};
if (count === 0) return null;
return (
<span className={cn('inline-flex rounded border px-1.5 py-0.5 font-mono text-[10px]', tones[kind])} data-testid={`mismatch-${kind}`}>
{count} {labels[kind]}
</span>
);
}
export default function ComposeLabelsPanel({ stackName }: { stackName: string }) {
const { isAdmin } = useAuth();
const [inventory, setInventory] = useState<StackLabelInventory | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [revealSecrets, setRevealSecrets] = useState(false);
const [search, setSearch] = useState('');
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
const [hiddenServices, setHiddenServices] = useState<Set<string>>(new Set());
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
setLoading(true);
setLoadError(false);
try {
const qs = reveal ? '?reveal=1' : '';
const res = await apiFetch(`/stacks/${stackName}/label-inventory${qs}`);
if (!res.ok) {
setLoadError(true);
throw new Error('Failed to load Compose label inventory');
}
setInventory(await res.json() as StackLabelInventory);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load Compose label inventory');
setInventory(null);
} finally {
setLoading(false);
}
}, [stackName, revealSecrets]);
useEffect(() => {
void fetchInventory();
}, [fetchInventory]);
// Reset filters when the stack changes so selections never leak between stacks. Not keyed on
// reveal/refetch, so filters are preserved across a reveal on the same stack.
useEffect(() => {
setSearch('');
setExcludedSources(new Set());
setHiddenServices(new Set());
}, [stackName]);
const filtersActive = search.trim() !== '' || excludedSources.size > 0;
// Count of active facet filters for the Filters popover badge (search is its own control).
const filterCount = excludedSources.size + hiddenServices.size;
const sourceFacets = useMemo(() => {
const srcs: LabelSource[] = [];
for (const svc of inventory?.services ?? []) {
for (const l of svc.declaredLabels) srcs.push(l.source);
for (const rep of svc.replicas) for (const l of rep.runtimeLabels) srcs.push(l.source);
}
return sourcesPresent(srcs);
}, [inventory]);
const serviceNames = useMemo(() => (inventory?.services ?? []).map(s => s.service), [inventory]);
// Filter-aware view model: computed before render so badges reflect only visible labels and
// empty/failure states stay truthful. Per-service checkbox is the only thing that hides a card.
const serviceViews = useMemo(() => {
return (inventory?.services ?? [])
.filter(svc => !hiddenServices.has(svc.service))
.map(svc => {
const serviceHit = matchesSearch(search, svc.service);
const declaredVisible = svc.declaredLabels.filter(l => labelVisible(l, search, excludedSources, serviceHit));
const declaredVisibleKeys = new Set(declaredVisible.map(l => l.key));
const replicas = svc.replicas.map(rep => {
if (rep.inspectFailed) {
return { rep, inspectFailed: true as const, runtimeVisible: [] as LabelValue[], badges: { onlyInCompose: 0, onlyOnContainer: 0, inBoth: 0, changed: 0 }, render: true };
}
const replicaHit = serviceHit || matchesSearch(search, rep.name, rep.id);
const runtimeVisible = rep.runtimeLabels.filter(l => labelVisible(l, search, excludedSources, replicaHit));
const rtKeys = new Set(runtimeVisible.map(l => l.key));
const badges = {
onlyInCompose: rep.onlyInCompose.filter(k => declaredVisibleKeys.has(k)).length,
onlyOnContainer: rep.onlyOnContainer.filter(k => rtKeys.has(k)).length,
inBoth: rep.inBoth.filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
changed: (rep.changed ?? []).filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
};
const anyBadge = badges.onlyInCompose + badges.onlyOnContainer + badges.inBoth + badges.changed > 0;
const render = runtimeVisible.length > 0 || anyBadge || !filtersActive;
return { rep, inspectFailed: false as const, runtimeVisible, badges, render };
});
const renderedReplicas = replicas.filter(r => r.render);
const noRunning = svc.replicas.length === 0 && svc.declaredLabels.length > 0 && (!filtersActive || declaredVisible.length > 0);
const hasContent = declaredVisible.length > 0 || renderedReplicas.length > 0 || noRunning;
return { svc, declaredVisible, replicas: renderedReplicas, noRunning, hasContent };
});
}, [inventory, hiddenServices, search, excludedSources, filtersActive]);
const toggleSource = (s: LabelSource) => {
setExcludedSources(prev => {
const next = new Set(prev);
if (next.has(s)) next.delete(s); else next.add(s);
return next;
});
};
const toggleService = (s: string) => {
setHiddenServices(prev => {
const next = new Set(prev);
if (next.has(s)) next.delete(s); else next.add(s);
return next;
});
};
const handleReveal = () => {
setRevealSecrets(true);
void fetchInventory(true);
};
if (loading && !inventory) {
return <p className="px-3 py-3 font-mono text-[11px] text-stat-subtitle">Loading Compose labels</p>;
}
if (loadError) {
return <p className="px-3 py-3 font-mono text-[11px] text-destructive">Could not load Compose labels for this stack.</p>;
}
const noServices = (inventory?.services.length ?? 0) === 0;
const allServicesHidden = !noServices && serviceNames.every(s => hiddenServices.has(s));
return (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4" data-testid="compose-labels-panel">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>compose labels</span>
</div>
<p className="text-[11px] leading-relaxed text-stat-subtitle">{LABEL_DISAMBIGUATION_COPY}</p>
{!noServices && (
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<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, services..."
className="h-9 pl-8"
data-testid="compose-label-search"
/>
</div>
{(sourceFacets.length > 0 || serviceNames.length > 1) && (
<Popover>
<PopoverTrigger asChild>
<Button variant={filterCount > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
<SlidersHorizontal className="w-4 h-4" />
Filters
{filterCount > 0 && (
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{filterCount}</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 space-y-4">
{sourceFacets.length > 0 && (
<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">
{sourceFacets.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>
)}
{serviceNames.length > 1 && (
<div className="space-y-1.5">
<label className={FILTER_SECTION_LABEL_CLASS}>Services</label>
<div className="flex flex-wrap items-center gap-1.5">
{serviceNames.map(name => (
<Button
key={name}
variant={!hiddenServices.has(name) ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5 font-mono"
aria-pressed={!hiddenServices.has(name)}
onClick={() => toggleService(name)}
>
{name}
</Button>
))}
</div>
</div>
)}
{filterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="w-full h-8 text-xs"
onClick={() => { setExcludedSources(new Set()); setHiddenServices(new Set()); }}
>
Clear filters
</Button>
)}
</PopoverContent>
</Popover>
)}
</div>
)}
{!inventory?.renderable && (
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>Compose could not be fully rendered. Declared labels may be incomplete.</span>
</div>
)}
{inventory?.partial && (
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>Some containers or images could not be inspected. Label provenance may be incomplete.</span>
</div>
)}
{noServices && (
<p className="text-sm text-stat-subtitle">No Compose or runtime labels found for this stack.</p>
)}
{allServicesHidden && (
<p className="text-sm text-stat-subtitle" data-testid="compose-labels-no-services">No services selected.</p>
)}
{serviceViews.map(({ svc, declaredVisible, replicas, noRunning, hasContent }) => (
<div key={svc.service} className={CARD_CLASS} data-testid="compose-label-service">
<p className={LABEL_CLASS}>{svc.service}</p>
{declaredVisible.length > 0 && (
<div className="mt-3">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">Declared in Compose</p>
{declaredVisible.map((label) => (
<LabelRow key={`decl-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
))}
</div>
)}
{replicas.map(({ rep, inspectFailed, runtimeVisible, badges }) => (
<div key={rep.id || rep.name} className="mt-3 border-t border-muted pt-3 first:mt-0 first:border-t-0 first:pt-0">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">
{rep.name || rep.id.slice(0, 12)} · {rep.state}
</p>
{inspectFailed ? (
<p className="text-xs text-warning">Runtime labels unavailable for this container.</p>
) : (
<>
{(badges.onlyInCompose > 0 || badges.onlyOnContainer > 0 || badges.changed > 0) && (
<div className="flex flex-wrap gap-1.5 mb-2">
<MismatchBadge kind="only-compose" count={badges.onlyInCompose} />
<MismatchBadge kind="changed" count={badges.changed} />
<MismatchBadge kind="only-container" count={badges.onlyOnContainer} />
<MismatchBadge kind="both" count={badges.inBoth} />
</div>
)}
{runtimeVisible.length > 0 ? (
runtimeVisible.map((label) => (
<LabelRow key={`rt-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
))
) : (
!filtersActive && <p className="text-xs text-stat-subtitle">No runtime labels on this container.</p>
)}
</>
)}
</div>
))}
{noRunning && (
<p className="mt-2 text-xs text-stat-subtitle">
No running containers for this service. Container may need redeploy for Compose label changes to apply.
</p>
)}
{filtersActive && !hasContent && (
<p className="mt-2 text-xs text-stat-subtitle" data-testid="compose-label-service-no-match">No labels match the filter.</p>
)}
</div>
))}
<p className="text-[10px] text-stat-subtitle leading-relaxed">
Runtime labels are static until the container is recreated. Container may need redeploy for Compose label changes to apply.
</p>
</div>
);
}
@@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ComposeLabelsPanel from '../ComposeLabelsPanel';
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 mockInventory = {
stackName: 'demo',
renderable: true,
generatedAt: Date.now(),
services: [
{
service: 'web',
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
replicas: [
{
id: 'c1',
name: 'demo-web-1',
state: 'running',
runtimeLabels: [
{ key: 'traefik.enable', value: 'true', source: 'runtime' as const },
{ key: 'runtime.only', value: '1', source: 'runtime' as const },
],
onlyInCompose: [],
onlyOnContainer: ['runtime.only'],
inBoth: ['traefik.enable'],
},
],
},
],
};
describe('ComposeLabelsPanel', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => mockInventory,
} as Response);
});
it('shows disambiguation copy and service labels', async () => {
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.getAllByText('traefik.enable')).toHaveLength(2);
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
expect(screen.getByTestId('mismatch-both')).toBeInTheDocument();
});
it('renders a value-changed badge when a label value drifted', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
});
it('shows "Runtime labels unavailable" for a replica whose inspect failed', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
inspectFailed: true,
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
expect(screen.getByText(/could not be inspected/i)).toBeInTheDocument();
});
// A service whose declared/runtime labels have distinctive keys so name-search can be isolated.
const searchMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'aaa.declared', value: '1', source: 'compose' as const }],
replicas: [{
id: 'w1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'zzz.runtime', value: 'q', source: 'runtime' as const }],
onlyInCompose: ['aaa.declared'], onlyOnContainer: ['zzz.runtime'], inBoth: [], changed: [],
}],
}],
};
it('service-name search exposes that service\'s declared and runtime labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'web');
expect(screen.getByText('aaa.declared')).toBeInTheDocument();
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
});
it('replica-name search exposes runtime labels only, not service-level declared labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'demo-web-1');
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
expect(screen.queryByText('aaa.declared')).toBeNull();
});
it('hides inBoth and changed badges when Compose File is filtered out', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(screen.queryByTestId('mismatch-changed')).toBeNull();
});
it('a source facet hides that source; turning off Compose File hides declared labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('aaa.declared')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(screen.queryByText('aaa.declared')).toBeNull();
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
});
it('recomputes badge counts from the visible set', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
// Search only the runtime key: onlyOnContainer stays 1, onlyInCompose drops to 0.
await user.type(await screen.findByTestId('compose-label-search'), 'zzz.runtime');
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
expect(screen.queryByTestId('mismatch-only-compose')).toBeNull();
});
it('shows the genuine "No runtime labels" for an empty replica when no filters are active', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }],
replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: ['a'], onlyOnContainer: [], inBoth: [], changed: [] }],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('No runtime labels on this container.')).toBeInTheDocument();
});
it('keeps the inspectFailed warning under an active filter but hides it when the service is unchecked', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [], replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], inspectFailed: true }] },
{ service: 'db', declaredLabels: [{ key: 'x', value: '1', source: 'compose' as const }], replicas: [] },
],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
// Active text filter that matches nothing: the inspectFailed warning must still show.
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
expect(screen.getByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
// Hiding the web service (via the Filters popover) removes its card and the warning.
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'web' }));
expect(screen.queryByText('Runtime labels unavailable for this container.')).toBeNull();
});
it('distinguishes genuine-empty inventory from filtered-empty (no duplicate messages)', async () => {
const user = userEvent.setup();
// Genuine empty inventory.
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({ stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), services: [] }),
} as Response);
const { unmount } = render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('No Compose or runtime labels found for this stack.')).toBeInTheDocument();
unmount();
// Filtered-empty: one per-card message, no panel-level duplicate.
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
expect(screen.getByTestId('compose-label-service-no-match')).toBeInTheDocument();
expect(screen.queryByText('No Compose or runtime labels found for this stack.')).toBeNull();
});
it('shows "No services selected" when every service checkbox is unchecked', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }], replicas: [] },
{ service: 'db', declaredLabels: [{ key: 'b', value: '2', source: 'compose' as const }], replicas: [] },
],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.click(await screen.findByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'web' }));
await user.click(screen.getByRole('button', { name: 'db' }));
expect(screen.getByTestId('compose-labels-no-services')).toBeInTheDocument();
});
it('preserves filters across a reveal on the same stack', async () => {
const user = userEvent.setup();
const revealMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web', declaredLabels: [],
replicas: [{
id: 'w1', name: 'demo-web-1', state: 'running',
runtimeLabels: [
{ key: 'api.token', value: '[redacted]', source: 'runtime' as const, redacted: true },
{ key: 'plain.label', value: 'v', source: 'runtime' as const },
],
onlyInCompose: [], onlyOnContainer: ['api.token', 'plain.label'], inBoth: [], changed: [],
}],
}],
};
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => revealMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'api.token');
expect(screen.getByText('api.token')).toBeInTheDocument();
expect(screen.queryByText('plain.label')).toBeNull();
await user.click(screen.getByRole('button', { name: /reveal/i }));
// The reset effect is keyed on stackName only, so the reveal-driven refetch keeps the filter.
expect((screen.getByTestId('compose-label-search') as HTMLInputElement).value).toBe('api.token');
expect(screen.queryByText('plain.label')).toBeNull();
});
it('resets search, source, and service filters when the stack changes', async () => {
const user = userEvent.setup();
const twoServiceMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [{ key: 'web.owner', value: '1', source: 'compose' as const }], replicas: [] },
{ service: 'db', declaredLabels: [{ key: 'db.owner', value: '2', source: 'compose' as const }], replicas: [] },
],
};
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => twoServiceMock } as Response);
const { rerender } = render(<ComposeLabelsPanel stackName="demo" />);
const input = await screen.findByTestId('compose-label-search') as HTMLInputElement;
await user.type(input, 'zzz');
// Hide the db service and exclude the Compose File source via the Filters popover.
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'db' }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(input.value).toBe('zzz');
expect(screen.queryByText('web.owner')).toBeNull();
expect(screen.queryByText('db.owner')).toBeNull();
rerender(<ComposeLabelsPanel stackName="other" />);
// Filters reset: search cleared, both services and the source shown again.
expect((await screen.findByTestId('compose-label-search') as HTMLInputElement).value).toBe('');
expect(await screen.findByText('web.owner')).toBeInTheDocument();
expect(screen.getByText('db.owner')).toBeInTheDocument();
});
});