mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat: add Docker label audit across Fleet and Stack views (#1531)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user