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

This commit is contained in:
Anso
2026-07-03 18:26:09 -04:00
committed by GitHub
parent 10fb93dcb1
commit 4a350e7a0a
27 changed files with 3099 additions and 1 deletions
@@ -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);
});
});
+1
View File
@@ -28,6 +28,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'container-label-inventory',
'project-env-files',
'compose-storage',
'cross-node-rbac',
+1
View File
@@ -42,6 +42,7 @@ export type FleetTab =
| 'snapshots'
| 'configuration'
| 'dependencies'
| 'container-labels'
| 'deployments'
| 'routing'
| 'federation'
+117
View File
@@ -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));
}