mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sourcesPresent, matchesSearch, type LabelSource } from '@/lib/labelInventory';
|
||||
|
||||
describe('sourcesPresent', () => {
|
||||
it('returns distinct sources in the fixed display order regardless of input order', () => {
|
||||
const input: LabelSource[] = ['runtime', 'unknown', 'compose', 'image', 'compose-system'];
|
||||
expect(sourcesPresent(input)).toEqual(['compose', 'image', 'runtime', 'compose-system', 'unknown']);
|
||||
});
|
||||
|
||||
it('de-duplicates and omits sources not present', () => {
|
||||
expect(sourcesPresent(['runtime', 'runtime', 'image'])).toEqual(['image', 'runtime']);
|
||||
expect(sourcesPresent([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesSearch', () => {
|
||||
it('matches when any part contains the (case-insensitive) query', () => {
|
||||
expect(matchesSearch('WATCH', 'com.centurylinklabs.watchtower.enable')).toBe(true);
|
||||
expect(matchesSearch('nope', 'traefik.enable', 'true')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty or whitespace query as matching everything', () => {
|
||||
expect(matchesSearch('', 'anything')).toBe(true);
|
||||
expect(matchesSearch(' ', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('trims the query and tolerates null/undefined parts', () => {
|
||||
expect(matchesSearch(' enable ', 'traefik.enable')).toBe(true);
|
||||
expect(matchesSearch('x', null, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'container-label-inventory',
|
||||
'project-env-files',
|
||||
'compose-storage',
|
||||
'cross-node-rbac',
|
||||
|
||||
@@ -42,6 +42,7 @@ export type FleetTab =
|
||||
| 'snapshots'
|
||||
| 'configuration'
|
||||
| 'dependencies'
|
||||
| 'container-labels'
|
||||
| 'deployments'
|
||||
| 'routing'
|
||||
| 'federation'
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown';
|
||||
|
||||
export interface LabelValue {
|
||||
key: string;
|
||||
value: string;
|
||||
source: LabelSource;
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerLabelRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
state: string;
|
||||
labels: LabelValue[];
|
||||
}
|
||||
|
||||
export interface LabelIndexContainerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
nodeId?: number;
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface LabelIndexRow {
|
||||
key: string;
|
||||
value: string;
|
||||
redacted?: boolean;
|
||||
source: LabelSource;
|
||||
containers: LabelIndexContainerRef[];
|
||||
}
|
||||
|
||||
export interface FleetLabelInventoryResponse {
|
||||
nodes: Array<{
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
status: 'ok' | 'error';
|
||||
inventory: {
|
||||
nodeId: number;
|
||||
containers: ContainerLabelRow[];
|
||||
byLabel: LabelIndexRow[];
|
||||
partial: boolean;
|
||||
generatedAt: number;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
aggregatedByLabel: LabelIndexRow[];
|
||||
nodeErrors: Record<number, string>;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface StackLabelReplica {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
runtimeLabels: LabelValue[];
|
||||
onlyInCompose: string[];
|
||||
onlyOnContainer: string[];
|
||||
inBoth: string[];
|
||||
// Optional so older nodes (pre-provenance) still typecheck during mixed-version operation.
|
||||
changed?: string[];
|
||||
inspectFailed?: boolean;
|
||||
}
|
||||
|
||||
export interface StackServiceLabelRow {
|
||||
service: string;
|
||||
declaredLabels: LabelValue[];
|
||||
replicas: StackLabelReplica[];
|
||||
}
|
||||
|
||||
export interface StackLabelInventory {
|
||||
stackName: string;
|
||||
renderable: boolean;
|
||||
services: StackServiceLabelRow[];
|
||||
// Optional so older nodes still typecheck during mixed-version operation.
|
||||
partial?: boolean;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export const LABEL_DISAMBIGUATION_COPY =
|
||||
'Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement.';
|
||||
|
||||
export const SOURCE_LABELS: Record<LabelSource, string> = {
|
||||
compose: 'Declared in Compose',
|
||||
runtime: 'Present at runtime',
|
||||
image: 'Image',
|
||||
'compose-system': 'Docker Compose system label',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
/** Concise facet-chip labels (concept-aligned with SOURCE_LABELS, not a rename of the badges). */
|
||||
export const SOURCE_FACET_LABELS: Record<LabelSource, string> = {
|
||||
compose: 'Compose File',
|
||||
runtime: 'Runtime',
|
||||
image: 'Image',
|
||||
'compose-system': 'System',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
/** Stable display order for source facets. */
|
||||
const SOURCE_ORDER: LabelSource[] = ['compose', 'image', 'runtime', 'compose-system', 'unknown'];
|
||||
|
||||
/** The distinct sources present, in stable display order, for building data-driven facet rows. */
|
||||
export function sourcesPresent(sources: Iterable<LabelSource>): LabelSource[] {
|
||||
const present = new Set(sources);
|
||||
return SOURCE_ORDER.filter(s => present.has(s));
|
||||
}
|
||||
|
||||
/** Case-insensitive substring match across a variable list of fields; empty query matches all. */
|
||||
export function matchesSearch(query: string, ...parts: (string | null | undefined)[]): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return parts.some(p => (p ?? '').toLowerCase().includes(q));
|
||||
}
|
||||
Reference in New Issue
Block a user