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
@@ -978,6 +978,59 @@ describe('DockerController - inspectImage', () => {
});
});
// --- label / image inspection for the label inventory --------------------------
describe('DockerController - inspectImageLabels', () => {
it('returns the image label map', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImageLabels('sha256:img');
expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
});
it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('No such image')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('missing')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('short-circuits an empty image id without inspecting', async () => {
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('')).toBeNull();
expect(mockDocker.getImage).not.toHaveBeenCalled();
});
});
describe('DockerController - inspectContainerLabelsAndImage', () => {
it('returns labels and the image ref from container inspect', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectContainerLabelsAndImage('c1');
expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
});
it('returns null and logs when the container inspect fails', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('no such container')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
// --- createNetwork validation --------------------------------------------------
describe('createNetwork', () => {
@@ -21,6 +21,7 @@ const NODE_READ_ROUTES = [
'/api/fleet/dependency-map',
'/api/fleet/networking-summary',
'/api/fleet/update-status',
'/api/fleet/container-labels',
];
beforeAll(async () => {
@@ -61,4 +62,20 @@ describe('fleet topology reads require node:read', () => {
const res = await request(app).get('/api/fleet/overview');
expect(res.status).toBe(401);
});
it('denies ?reveal=1 on container-labels for a non-admin (viewer)', async () => {
const res = await request(app).get('/api/fleet/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
});
it('denies the node-local /api/system/container-labels for a role without node:read (deployer)', async () => {
const res = await request(app).get('/api/system/container-labels').set('Authorization', `Bearer ${deployerToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('denies ?reveal=1 on the node-local container-labels for a non-admin (viewer)', async () => {
const res = await request(app).get('/api/system/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
});
});
@@ -909,7 +909,9 @@ describe('GitSourceService.createStackFromGit', () => {
sha,
});
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
const result = await svc.createStackFromGit({
stackName: 'create-happy',
repoUrl: 'https://github.com/example/repo.git',
@@ -934,6 +936,9 @@ describe('GitSourceService.createStackFromGit', () => {
expect(onDisk).toContain('image: nginx');
await cleanupStackDir('create-happy');
} finally {
validateSpy.mockRestore();
}
});
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
@@ -0,0 +1,574 @@
/**
* Label inventory service, provenance, redaction, and GET routes.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import {
buildNodeLabelInventory,
buildStackLabelInventory,
} from '../services/LabelInventoryService';
import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction';
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let nodeId: number;
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
function writeStack(stack: string, files: Record<string, string>): void {
const dir = path.join(composeDir(), stack);
fs.mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
}
function stubRender(serviceLabels: Record<string, Record<string, string>> | null): void {
const rendered = serviceLabels === null
? null
: JSON.stringify({
name: 'proj',
services: Object.fromEntries(
Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]),
),
});
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }),
} as unknown as ComposeService);
}
interface StubRow {
id: string;
name: string;
state: string;
stack: string | null;
service: string | null;
labels: Record<string, string>;
inspectFailed?: boolean;
imageId?: string;
}
/**
* Stub DockerController for the label inventory. `images` maps an image id to its label
* map, or to `null` to simulate an image inspect failure. An image id absent from the map
* inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can
* assert deduplication.
*/
function stubDockerList(
rows: StubRow[],
opts: { images?: Record<string, Record<string, string> | null> } = {},
): { inspectImageLabels: ReturnType<typeof vi.fn> } {
const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r }));
const images = opts.images ?? {};
const inspectImageLabels = vi.fn(async (imageId: string) => {
if (imageId in images) {
const labels = images[imageId];
return labels === null ? null : { labels };
}
return { labels: {} };
});
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults),
getContainersByStack: vi.fn().mockImplementation(async (stack: string) =>
withDefaults.filter(r => r.stack === stack).map(r => ({
Id: r.id,
Names: [`/${r.name}`],
State: r.state,
Service: r.service,
})),
),
inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => {
const row = withDefaults.find(r => r.id === id);
if (!row || row.inspectFailed) return null;
return { labels: row.labels, imageId: row.imageId };
}),
inspectImageLabels,
} as unknown as DockerController);
return { inspectImageLabels };
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('buildNodeLabelInventory', () => {
it('builds inverted index and compose-system provenance', async () => {
stubDockerList([
{
id: 'c1',
name: 'web-1',
state: 'running',
stack: 'mys',
service: 'web',
imageId: 'img1',
labels: {
'com.docker.compose.service': 'web',
'traefik.enable': 'true',
},
},
]);
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers).toHaveLength(1);
expect(inv.byLabel).toHaveLength(2);
const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service');
expect(svc?.source).toBe('compose-system');
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(traefik?.source).toBe('runtime');
expect(inv.partial).toBe(false);
});
it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => {
stubDockerList([
{
id: 'c1',
name: 'plex-1',
state: 'running',
stack: 'media',
service: 'plex',
imageId: 'plex-img',
labels: {
'org.opencontainers.image.title': 'Plex',
'traefik.enable': 'true',
},
},
], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } });
const inv = await buildNodeLabelInventory(nodeId);
const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title');
expect(oci?.source).toBe('image');
// Same key on the image but a different value: the container overrides it, so runtime.
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(traefik?.source).toBe('runtime');
expect(inv.partial).toBe(false);
});
it('marks labels unknown and the inventory partial when the image inspect fails', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } },
], { images: { broken: null } });
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
});
it('treats an empty image id as unknown and partial without inspecting an empty id', async () => {
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } },
]);
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
expect(inspectImageLabels).not.toHaveBeenCalledWith('');
});
it('inspects each shared image only once (dedup)', async () => {
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
{ id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
], { images: { shared: {} } });
await buildNodeLabelInventory(nodeId);
const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared');
expect(sharedCalls).toHaveLength(1);
});
it('redacts secret-like label values by default', async () => {
stubDockerList([
{
id: 'c1',
name: 'app-1',
state: 'running',
stack: 'sec',
service: 'app',
imageId: 'img1',
labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' },
},
]);
const inv = await buildNodeLabelInventory(nodeId);
const token = inv.containers[0].labels.find(l => l.key === 'my.api.token');
expect(token?.value).toBe(REDACTED_SENTINEL);
expect(token?.redacted).toBe(true);
const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(plain?.value).toBe('true');
expect(plain?.redacted).toBeUndefined();
});
it('redacts Traefik basicauth and digestauth label values', async () => {
stubDockerList([
{
id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1',
labels: {
'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123',
'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef',
'traefik.enable': 'true',
},
},
]);
const inv = await buildNodeLabelInventory(nodeId);
const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users'));
const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users'));
expect(basic?.value).toBe(REDACTED_SENTINEL);
expect(basic?.redacted).toBe(true);
expect(digest?.value).toBe(REDACTED_SENTINEL);
expect(digest?.redacted).toBe(true);
expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined();
});
it('reveals secret-like values when revealSecrets is true', async () => {
stubDockerList([
{
id: 'c1',
name: 'app-1',
state: 'running',
stack: 'sec',
service: 'app',
imageId: 'img1',
labels: { 'api.token': 'visible-when-revealed' },
},
]);
const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true });
expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed');
expect(inv.containers[0].labels[0].redacted).toBeUndefined();
});
});
describe('buildStackLabelInventory', () => {
it('reconciles declared and runtime labels', async () => {
writeStack('lbl1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n',
});
stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } });
stubDockerList([
{
id: 'c1',
name: 'lbl1-web-1',
state: 'running',
stack: 'lbl1',
service: 'web',
imageId: 'img1',
labels: {
'traefik.enable': 'true',
'runtime.only': '1',
'com.docker.compose.service': 'web',
},
},
]);
const inv = await buildStackLabelInventory(nodeId, 'lbl1');
expect(inv.renderable).toBe(true);
expect(inv.partial).toBe(false);
const web = inv.services.find(s => s.service === 'web');
expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']);
expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']);
expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only');
expect(web?.replicas[0].inBoth).toContain('traefik.enable');
expect(web?.replicas[0].changed).toEqual([]);
});
it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => {
writeStack('drift1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n',
});
stubRender({ web: { 'watchtower.enable': 'true' } });
stubDockerList([
{
id: 'c1',
name: 'drift1-web-1',
state: 'running',
stack: 'drift1',
service: 'web',
imageId: 'img1',
labels: { 'watchtower.enable': 'false' },
},
]);
const inv = await buildStackLabelInventory(nodeId, 'drift1');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].changed).toEqual(['watchtower.enable']);
expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable');
});
it('detects drift on a secret-like key while its value stays redacted', async () => {
writeStack('drift2', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n',
});
stubRender({ web: { 'auth.token': 'declared' } });
stubDockerList([
{ id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } },
]);
const inv = await buildStackLabelInventory(nodeId, 'drift2');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].changed).toEqual(['auth.token']);
const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token');
expect(rt?.value).toBe(REDACTED_SENTINEL);
expect(rt?.redacted).toBe(true);
});
it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => {
writeStack('fail1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n',
});
stubRender({ web: { 'traefik.enable': 'true' } });
stubDockerList([
{ id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true },
]);
const inv = await buildStackLabelInventory(nodeId, 'fail1');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].inspectFailed).toBe(true);
expect(web?.replicas[0].onlyInCompose).toEqual([]);
expect(web?.replicas[0].runtimeLabels).toEqual([]);
expect(inv.partial).toBe(true);
});
it('inspects each shared image only once across replicas (dedup)', async () => {
writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
{ id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
], { images: { shared: {} } });
await buildStackLabelInventory(nodeId, 'ddup');
expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1);
});
it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => {
writeStack('prov1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n',
});
stubRender({ web: { foo: 'bar' } });
stubDockerList([
{ id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } },
], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } });
const inv = await buildStackLabelInventory(nodeId, 'prov1');
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
// foo is on both the image and the Compose file with the same value: Compose wins.
expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose');
expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image');
expect(inv.partial).toBe(false);
});
it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => {
writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
stubDockerList([
{ id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } },
], { images: { broken: null } });
const inv = await buildStackLabelInventory(nodeId, 'prov2');
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
});
it('parses list-form compose labels', async () => {
writeStack('lbl2', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n',
});
stubRender({ web: { 'watchtower.enable': 'true' } });
stubDockerList([]);
const inv = await buildStackLabelInventory(nodeId, 'lbl2');
expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' });
});
it('sets renderable false when compose render fails', async () => {
writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender(null);
stubDockerList([]);
const inv = await buildStackLabelInventory(nodeId, 'lbl3');
expect(inv.renderable).toBe(false);
});
it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => {
writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender(null);
stubDockerList([
{
id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1',
labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' },
},
]);
const inv = await buildStackLabelInventory(nodeId, 'rf1');
expect(inv.renderable).toBe(false);
// Render failure is signalled by renderable, not partial (which is for inspect failures).
expect(inv.partial).toBe(false);
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown');
expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system');
expect(rep?.onlyOnContainer).toEqual([]);
expect(rep?.changed).toEqual([]);
});
});
describe('GET /api/system/container-labels', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/system/container-labels');
expect(res.status).toBe(401);
});
it('returns node inventory', async () => {
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } },
]);
const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeId).toBe(nodeId);
expect(res.body.containers).toHaveLength(1);
});
it('redacts secrets by default and reveals them for an admin with reveal=1', async () => {
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
]);
const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
expect(rLabel.value).toBe(REDACTED_SENTINEL);
expect(rLabel.redacted).toBe(true);
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
]);
const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie);
const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
expect(vLabel.value).toBe('s3cr3t');
});
});
describe('GET /api/stacks/:stackName/label-inventory', () => {
it('returns 404 for unknown stack', async () => {
const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie);
expect(res.status).toBe(404);
});
it('returns stack inventory', async () => {
writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
stubDockerList([]);
const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.stackName).toBe('route1');
});
});
describe('GET /api/fleet/container-labels', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/fleet/container-labels');
expect(res.status).toBe(401);
});
it('aggregates the local node with no node errors', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } },
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } },
]);
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors).toEqual({});
const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label');
expect(shared).toHaveLength(1);
expect(shared[0].containers).toHaveLength(2);
});
it('keeps the same key=value distinct when the source differs', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } },
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } },
], { images: { ia: { 'dup.label': 'v' }, ib: {} } });
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label');
expect(dup).toHaveLength(2);
// The server sorts by key, value, then source; assert that order directly (no re-sort).
expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']);
});
it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true);
} finally {
db.deleteNode(remoteId);
}
});
it('degrades a malformed remote payload into nodeErrors, not a 500', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
it('rejects a remote row with an invalid source value via the source allowlist', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload');
} finally {
db.deleteNode(remoteId);
}
});
it('degrades a remote with a malformed inventory container into nodeErrors', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
it('rejects a remote row with a malformed nested container ref', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
});
@@ -0,0 +1,18 @@
import type { Request } from 'express';
import type { LabelInventoryOptions } from '../services/LabelInventoryService';
import { requireAdmin } from '../middleware/tierGates';
/** Parse ?reveal=1; full values only when the caller is an admin. */
export function labelInventoryOptionsFromRequest(req: Request): LabelInventoryOptions {
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
if (!wantsReveal) return { revealSecrets: false };
// requireAdmin is synchronous guard; routes call it before building inventory when reveal is requested.
return { revealSecrets: true };
}
/** Returns false and sends 403 when reveal was requested but caller is not admin. */
export function requireRevealAdmin(req: Request, res: import('express').Response): boolean {
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
if (!wantsReveal) return true;
return requireAdmin(req, res);
}
@@ -0,0 +1,24 @@
import { isLikelySecretKey } from './secretClassification';
export const REDACTED_SENTINEL = '[redacted]';
/**
* Compound single-token segments specific to Docker/Compose labels that the generic
* env classifier does not split (e.g. Traefik `basicauth`/`digestauth` middleware keys,
* whose value carries inline `user:passwordhash` credentials).
*/
const SECRET_LABEL_SEGMENTS = new Set(['BASICAUTH', 'DIGESTAUTH']);
/** True when a Docker/Compose label key likely carries a sensitive value. */
export function isLikelySecretLabelKey(rawKey: string): boolean {
if (isLikelySecretKey(rawKey)) return true;
const segments = rawKey.trim().toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
return segments.some(seg => SECRET_LABEL_SEGMENTS.has(seg));
}
export function redactLabelValue(key: string, value: string, revealSecrets: boolean): { value: string; redacted?: boolean } {
if (revealSecrets || !isLikelySecretLabelKey(key)) {
return { value };
}
return { value: REDACTED_SENTINEL, redacted: true };
}
@@ -16,6 +16,7 @@ const SECRET_SEGMENTS = new Set([
'SECRET', 'SECRETS',
'TOKEN', 'KEY', 'APIKEY',
'CREDENTIAL', 'CREDENTIALS', 'AUTH',
'BASIC',
]);
/** Connection strings whose value is sensitive but whose segments are innocuous. */
+165
View File
@@ -47,6 +47,8 @@ import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
@@ -727,6 +729,169 @@ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Res
}
});
interface FleetNodeLabelInventoryResult {
nodeId: number;
nodeName: string;
status: 'ok' | 'error';
inventory: NodeLabelInventory | null;
error: string | null;
}
function isStringOrNull(v: unknown): boolean {
return typeof v === 'string' || v === null;
}
function isLabelIndexContainerRef(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service);
}
function isLabelValue(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source);
}
function isContainerLabelRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& typeof o.state === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service)
&& Array.isArray(o.labels)
&& o.labels.every(isLabelValue);
}
function isLabelIndexRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source)
&& Array.isArray(o.containers)
&& o.containers.every(isLabelIndexContainerRef);
}
/**
* Validate a remote node's label-inventory payload deeply enough that neither the
* aggregation sort nor the Fleet UI ever receives a malformed row. A single bad
* `byLabel` or `containers` element would otherwise crash the whole fleet request (or
* the client) rather than degrading that node into `nodeErrors`. Only wire fields are
* checked; the internal `imageId` is not part of the shape sent over the wire.
*/
function isNodeLabelInventory(v: unknown): v is NodeLabelInventory {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.nodeId === 'number'
&& Array.isArray(o.containers)
&& o.containers.every(isContainerLabelRow)
&& Array.isArray(o.byLabel)
&& o.byLabel.every(isLabelIndexRow);
}
/**
* Fleet-wide Docker label inventory. Auth + node:read (Community). Fans out to
* each node's /api/system/container-labels; unreachable nodes degrade gracefully.
*/
fleetRouter.get('/container-labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
if (!requireRevealAdmin(req, res)) return;
const options = labelInventoryOptionsFromRequest(req);
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results = await Promise.allSettled(
nodes.map(async (node: Node): Promise<FleetNodeLabelInventoryResult> => {
if (node.type === 'local') {
const inventory = await buildNodeLabelInventory(node.id, options);
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: formatNoTargetError(node) };
}
const revealQs = options.revealSecrets ? '?reveal=1' : '';
const resp = await fetch(
`${target.apiUrl.replace(/\/$/, '')}/api/system/container-labels${revealQs}`,
{
headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) },
signal: AbortSignal.timeout(30000),
},
);
if (!resp.ok) {
const errBody = await resp.json().catch(() => null) as { error?: string } | null;
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: errBody?.error ?? `Remote returned ${resp.status}` };
}
const inventory = await resp.json().catch(() => null);
if (!isNodeLabelInventory(inventory)) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: 'Remote returned an unexpected label-inventory payload' };
}
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}),
);
const perNode: FleetNodeLabelInventoryResult[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Container labels fetch failed for node ${nodes[i].name}:`, result.reason);
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', inventory: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
});
const aggregatedByLabel = new Map<string, import('../services/LabelInventoryService').LabelIndexRow>();
for (const nodeResult of perNode) {
if (nodeResult.status !== 'ok' || !nodeResult.inventory) continue;
for (const row of nodeResult.inventory.byLabel) {
const key = `${row.key}\0${row.value}\0${row.source}`;
const existing = aggregatedByLabel.get(key);
if (!existing) {
aggregatedByLabel.set(key, {
...row,
containers: row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})),
});
} else {
existing.containers.push(...row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})));
}
}
}
const nodeErrors: Record<number, string> = {};
for (const n of perNode) {
if (n.status === 'error' && n.error) nodeErrors[n.nodeId] = n.error;
}
res.json({
nodes: perNode,
aggregatedByLabel: [...aggregatedByLabel.values()].sort((a, b) =>
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source)),
nodeErrors,
generatedAt: Date.now(),
});
} catch (error) {
console.error('[Fleet] Container labels error:', error);
res.status(500).json({ error: 'Failed to build fleet container label inventory' });
}
});
interface FleetNetworkingSummaryNode {
nodeId: number;
nodeName: string;
+18
View File
@@ -27,6 +27,8 @@ import { buildStackNetworkFacts } from '../services/network/composeNetworkInspec
import { buildStorageInventory } from '../services/storage/inventory';
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
import { buildEnvInventory } from '../services/EnvInventoryService';
import { buildStackLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
import { UpdateGuardService } from '../services/UpdateGuardService';
import { HealthGateService } from '../services/HealthGateService';
@@ -1322,6 +1324,22 @@ stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response
}
});
// Docker/Compose label inventory: declared compose labels vs runtime container
// labels per service. Read-only; auto-proxies to the active node.
stacksRouter.get('/:stackName/label-inventory', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
if (!requireRevealAdmin(req, res)) return;
try {
res.json(await buildStackLabelInventory(req.nodeId, stackName, labelInventoryOptionsFromRequest(req)));
} catch (error) {
console.error('[Stacks] Failed to build label inventory for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to build label inventory' });
}
});
// Exposure intent: the user's per-stack (service '') and per-service exposure
// classification, stored separately from generated facts so mismatches stay
// detectable. Rows are stored independently; precedence (a service row taking
+16
View File
@@ -9,6 +9,9 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import { buildNodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { requirePermission } from '../middleware/permissions';
// `docker system df` (the call backing estimateSystemReclaim) can take 30+
// seconds on Docker Desktop with many volumes; 8s matches the MonitorService
@@ -218,6 +221,19 @@ systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) =>
}
});
// Node-wide Docker/Compose label inventory for fleet fan-out and local audit.
systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
if (!requireRevealAdmin(req, res)) return;
try {
const inventory = await buildNodeLabelInventory(req.nodeId, labelInventoryOptionsFromRequest(req));
res.json(inventory);
} catch (error) {
console.error('Failed to build container label inventory:', error);
res.status(500).json({ error: 'Failed to build container label inventory' });
}
});
systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
@@ -40,6 +40,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'container-label-inventory',
'project-env-files',
'compose-storage',
'cross-node-rbac',
+99
View File
@@ -8,6 +8,7 @@ import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
import { CacheService } from './CacheService';
import { FileSystemService } from './FileSystemService';
import SelfIdentityService from './SelfIdentityService';
import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -222,6 +223,17 @@ export interface CreateNetworkOptions {
Attachable?: boolean;
}
export interface LabelInventoryRow {
id: string;
name: string;
state: string;
stack: string | null;
service: string | null;
labels: Record<string, string>;
imageId: string;
inspectFailed: boolean;
}
class DockerController {
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
/**
@@ -863,6 +875,93 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
/** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */
public async inspectContainerLabelsAndImage(
containerId: string,
): Promise<{ labels: Record<string, string>; imageId: string } | null> {
try {
const info = await this.docker.getContainer(containerId).inspect();
return { labels: info.Config?.Labels ?? {}, imageId: info.Image ?? '' };
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(containerId), err);
return null;
}
}
/** Image-level labels for label provenance. Null (logged) when the image cannot be inspected. */
public async inspectImageLabels(imageId: string): Promise<{ labels: Record<string, string> } | null> {
if (!imageId) return null;
try {
const info = await this.docker.getImage(imageId).inspect();
return { labels: info.Config?.Labels ?? {} };
} catch (err) {
console.error('[DockerController] Image inspect failed for %s:', sanitizeForLog(imageId), err);
return null;
}
}
/**
* Containers with runtime labels for the label-inventory API. Resolves stack
* membership with the same multi-fallback strategy as bulk status. Captures the
* image ref so label provenance can distinguish image-inherited labels.
*/
public async listContainersForLabelInventory(): Promise<LabelInventoryRow[]> {
const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks();
const listed = await this.getAllContainers() as Array<{
Id?: string;
Names?: string[];
State?: string;
Labels?: Record<string, string>;
ImageID?: string;
}>;
const projectToStack = await DockerController.resolveProjectNameMap(knownStacks);
const absDirToStack = DockerController.buildAbsDirMap(knownStacks);
const resolvedBase = path.resolve(COMPOSE_DIR);
const knownStackSet = new Set(knownStacks);
const CONCURRENCY = 8;
const results: LabelInventoryRow[] = new Array(listed.length);
let index = 0;
const worker = async () => {
while (index < listed.length) {
const i = index++;
const c = listed[i];
const id = c.Id ?? '';
const name = (c.Names?.[0] ?? '').replace(/^\//, '');
const state = c.State ?? 'unknown';
const stack = DockerController.resolveContainerStack(
c.Labels,
projectToStack,
knownStackSet,
absDirToStack,
resolvedBase,
);
const service = c.Labels?.['com.docker.compose.service'] ?? null;
if (!id) {
results[i] = { id, name, state, stack, service, labels: {}, imageId: c.ImageID ?? '', inspectFailed: true };
continue;
}
let labels: Record<string, string>;
let imageId: string;
let inspectFailed = false;
try {
const info = await this.docker.getContainer(id).inspect();
labels = info.Config?.Labels ?? {};
imageId = info.Image ?? '';
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(id), err);
labels = c.Labels ?? {};
imageId = c.ImageID ?? '';
inspectFailed = true;
}
results[i] = { id, name, state, stack, service, labels, imageId, inspectFailed };
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, listed.length) }, worker));
return results;
}
/** Resolve a container by its durable name (not ephemeral ID). */
public async findContainerByName(name: string): Promise<{
id: string;
@@ -0,0 +1,415 @@
/**
* Per-node and per-stack Docker/Compose label inventory with provenance and
* optional value redaction for secret-like keys.
*/
import { ComposeService } from './ComposeService';
import DockerController from './DockerController';
import { redactLabelValue } from '../helpers/labelValueRedaction';
import { sanitizeForLog } from '../utils/safeLog';
export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown';
/**
* Every valid label source, typed as a set of strings so the wire validator can test an
* untrusted `string` without a cast. The literals are `LabelSource` members, so the union
* still documents the valid values.
*/
export const VALID_LABEL_SOURCES: ReadonlySet<string> = new Set<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 NodeLabelInventory {
nodeId: number;
containers: ContainerLabelRow[];
byLabel: LabelIndexRow[];
partial: boolean;
generatedAt: number;
}
export interface StackLabelReplica {
id: string;
name: string;
state: string;
runtimeLabels: LabelValue[];
onlyInCompose: string[];
onlyOnContainer: string[];
inBoth: string[];
/** Keys declared in Compose and present at runtime but with a different value. */
changed: string[];
/** Runtime labels could not be read for this replica; reconciliation is skipped. */
inspectFailed?: boolean;
}
export interface StackServiceLabelRow {
service: string;
declaredLabels: LabelValue[];
replicas: StackLabelReplica[];
}
export interface StackLabelInventory {
stackName: string;
renderable: boolean;
services: StackServiceLabelRow[];
/** A replica or its image could not be fully inspected; some provenance is unknown. */
partial: boolean;
generatedAt: number;
}
export interface LabelInventoryOptions {
revealSecrets?: boolean;
}
const INSPECT_CONCURRENCY = 8;
const COMPOSE_SYSTEM_PREFIX = 'com.docker.compose.';
function str(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return undefined;
}
/**
* Truthful provenance for a runtime label. Precedence: compose-system prefix, then
* (stack path only) a Compose-declared key with the same value, then an exact image
* label match, then plain runtime. When the image could not be inspected
* (`imageLabels === null`) an otherwise-unattributable label is `unknown`, not `runtime`.
*/
function resolveLabelSource(
key: string,
value: string,
imageLabels: Record<string, string> | null,
declared?: Record<string, string>,
): LabelSource {
if (key.startsWith(COMPOSE_SYSTEM_PREFIX)) return 'compose-system';
if (declared && declared[key] === value) return 'compose';
if (imageLabels) return imageLabels[key] === value ? 'image' : 'runtime';
return 'unknown';
}
/**
* Inspect each unique, non-empty image id once (deduped, bounded concurrency). Returns a
* map from image id to its label map, or `null` for an image that could not be inspected,
* and whether any inspection failed (so callers can mark the inventory partial).
*/
async function buildImageLabelMap(
docker: DockerController,
imageIds: string[],
): Promise<{ map: Map<string, Record<string, string> | null>; partial: boolean }> {
const unique = [...new Set(imageIds.filter(id => id.length > 0))];
const inspected = await mapWithConcurrency(unique, INSPECT_CONCURRENCY, async (imageId) => {
const result = await docker.inspectImageLabels(imageId);
return { imageId, labels: result ? result.labels : null };
});
const map = new Map<string, Record<string, string> | null>();
let partial = false;
for (const { imageId, labels } of inspected) {
map.set(imageId, labels);
if (labels === null) partial = true;
}
return { map, partial };
}
function parseLabelsMap(labels: unknown): Record<string, string> {
if (Array.isArray(labels)) {
const out: Record<string, string> = {};
for (const entry of labels) {
const raw = str(entry);
if (!raw) continue;
const eq = raw.indexOf('=');
if (eq === -1) {
out[raw] = '';
} else {
out[raw.slice(0, eq)] = raw.slice(eq + 1);
}
}
return out;
}
if (labels && typeof labels === 'object') {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(labels as Record<string, unknown>)) {
const val = str(v);
if (val !== undefined) out[k] = val;
}
return out;
}
return {};
}
function toLabelValue(
key: string,
value: string,
source: LabelSource,
revealSecrets: boolean,
): LabelValue {
const redacted = redactLabelValue(key, value, revealSecrets);
return { key, value: redacted.value, source, ...(redacted.redacted ? { redacted: true } : {}) };
}
function stripContainerName(names: string[] | undefined): string {
const first = names?.[0];
if (!first) return '';
return first.replace(/^\//, '');
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let index = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (index < items.length) {
const i = index++;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function buildByLabelIndex(
containers: ContainerLabelRow[],
nodeId?: number,
nodeName?: string,
): LabelIndexRow[] {
const map = new Map<string, LabelIndexRow>();
for (const container of containers) {
for (const label of container.labels) {
const mapKey = `${label.key}\0${label.value}\0${label.source}`;
let row = map.get(mapKey);
if (!row) {
row = {
key: label.key,
value: label.value,
source: label.source,
...(label.redacted ? { redacted: true } : {}),
containers: [],
};
map.set(mapKey, row);
}
row.containers.push({
id: container.id,
name: container.name,
stack: container.stack,
service: container.service,
...(nodeId !== undefined ? { nodeId } : {}),
...(nodeName !== undefined ? { nodeName } : {}),
});
}
}
return [...map.values()].sort((a, b) =>
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source));
}
function reconcileKeys(
declared: Record<string, string>,
runtime: Record<string, string>,
): { onlyInCompose: string[]; onlyOnContainer: string[]; inBoth: string[]; changed: string[] } {
const runtimeKeys = new Set(Object.keys(runtime));
const onlyInCompose: string[] = [];
const onlyOnContainer: string[] = [];
const inBoth: string[] = [];
const changed: string[] = [];
for (const k of Object.keys(declared)) {
if (!runtimeKeys.has(k)) onlyInCompose.push(k);
else if (declared[k] === runtime[k]) inBoth.push(k);
else changed.push(k);
}
for (const k of runtimeKeys) {
if (!(k in declared)) onlyOnContainer.push(k);
}
onlyInCompose.sort();
onlyOnContainer.sort();
inBoth.sort();
changed.sort();
return { onlyInCompose, onlyOnContainer, inBoth, changed };
}
/** Node-wide Docker label inventory for fleet and system routes. */
export async function buildNodeLabelInventory(
nodeId: number,
options: LabelInventoryOptions = {},
): Promise<NodeLabelInventory> {
const revealSecrets = options.revealSecrets === true;
const docker = DockerController.getInstance(nodeId);
const rows = await docker.listContainersForLabelInventory();
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, rows.map(r => r.imageId));
let missingImage = false;
const containers: ContainerLabelRow[] = rows.map((row) => {
const imageLabels = row.imageId ? (imageLabelMap.get(row.imageId) ?? null) : null;
if (!row.imageId) missingImage = true;
return {
id: row.id,
name: row.name,
stack: row.stack,
service: row.service,
state: row.state,
labels: Object.entries(row.labels).map(([key, value]) =>
toLabelValue(key, value, resolveLabelSource(key, value, imageLabels), revealSecrets),
).sort((a, b) => a.key.localeCompare(b.key)),
};
});
return {
nodeId,
containers,
byLabel: buildByLabelIndex(containers),
partial: rows.some(r => r.inspectFailed) || imagePartial || missingImage,
generatedAt: Date.now(),
};
}
/** Per-stack declared vs runtime label reconciliation for Stack Anatomy. */
export async function buildStackLabelInventory(
nodeId: number,
stackName: string,
options: LabelInventoryOptions = {},
): Promise<StackLabelInventory> {
const revealSecrets = options.revealSecrets === true;
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
let renderable = false;
// A failed render leaves the declared map empty. Without the declared model we cannot
// tell a Compose-declared label from a runtime one, so non-system runtime labels are
// resolved to `unknown` and reconciliation is skipped rather than shown as false drift.
// `renderable === false` (not `partial`) signals this to the UI; `partial` is reserved
// for inspection failures so the two banners stay distinct.
let renderFailed = false;
const declaredByService = new Map<string, Record<string, string>>();
if (result.rendered !== null) {
try {
const parsed = JSON.parse(result.rendered) as { services?: Record<string, { labels?: unknown }> };
for (const [serviceName, svc] of Object.entries(parsed.services ?? {})) {
declaredByService.set(serviceName, parseLabelsMap(svc.labels));
}
renderable = true;
} catch (err) {
console.error('[LabelInventory] Failed to parse rendered compose for stack %s:', sanitizeForLog(stackName), err);
renderFailed = true;
}
} else {
console.error('[LabelInventory] Compose render failed for stack %s (code %s): %s',
sanitizeForLog(stackName), result.code, sanitizeForLog(result.stderr));
renderFailed = true;
}
const docker = DockerController.getInstance(nodeId);
const stackContainers = await docker.getContainersByStack(stackName) as Array<{ Id?: string; Names?: string[]; State?: string; Service?: string }>;
const inspected = await mapWithConcurrency(stackContainers, INSPECT_CONCURRENCY, async (c) => {
const id = c.Id ?? '';
const name = stripContainerName(c.Names);
const state = c.State ?? 'unknown';
const service = c.Service ?? null;
const result = id ? await docker.inspectContainerLabelsAndImage(id) : null;
return { id, name, state, service, labels: result?.labels ?? {}, imageId: result?.imageId ?? '', inspectFailed: result === null };
});
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, inspected.map(r => r.imageId));
let partial = imagePartial;
const replicasByService = new Map<string, typeof inspected>();
for (const replica of inspected) {
const svc = replica.service ?? '_unknown';
const list = replicasByService.get(svc) ?? [];
list.push(replica);
replicasByService.set(svc, list);
}
const serviceNames = new Set<string>([
...declaredByService.keys(),
...replicasByService.keys(),
]);
serviceNames.delete('_unknown');
const services: StackServiceLabelRow[] = [...serviceNames].sort().map((service) => {
const declared = declaredByService.get(service) ?? {};
const declaredLabels = Object.entries(declared)
.map(([key, value]) => toLabelValue(key, value, 'compose', revealSecrets))
.sort((a, b) => a.key.localeCompare(b.key));
const replicas: StackLabelReplica[] = (replicasByService.get(service) ?? []).map((rep) => {
// A failed inspect has no runtime labels; reconciling against {} would falsely
// report every declared label as Compose-only, so skip reconciliation and flag it.
if (rep.inspectFailed) {
partial = true;
return {
id: rep.id, name: rep.name, state: rep.state,
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
inspectFailed: true,
};
}
const imageLabels = rep.imageId ? (imageLabelMap.get(rep.imageId) ?? null) : null;
if (!rep.imageId) partial = true;
// Without a declared model, only compose-system keys can be attributed with
// confidence; everything else is unknown, and reconciliation is skipped.
const runtimeLabels = Object.entries(rep.labels)
.map(([key, value]) => {
const source: LabelSource = renderFailed && !key.startsWith(COMPOSE_SYSTEM_PREFIX)
? 'unknown'
: resolveLabelSource(key, value, imageLabels, declared);
return toLabelValue(key, value, source, revealSecrets);
})
.sort((a, b) => a.key.localeCompare(b.key));
const runtimeMap = Object.fromEntries(Object.entries(rep.labels));
const { onlyInCompose, onlyOnContainer, inBoth, changed } = renderFailed
? { onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [] }
: reconcileKeys(declared, runtimeMap);
return {
id: rep.id,
name: rep.name,
state: rep.state,
runtimeLabels,
onlyInCompose,
onlyOnContainer,
inBoth,
changed,
};
});
return { service, declaredLabels, replicas };
});
return {
stackName,
renderable,
services,
partial,
generatedAt: Date.now(),
};
}
+1
View File
@@ -108,6 +108,7 @@
"features/compose-doctor",
"features/compose-networking",
"features/environment-guardrails",
"features/docker-label-audit",
"features/compose-storage",
"features/stack-labels",
"features/sidebar"
+55
View File
@@ -0,0 +1,55 @@
---
title: "Docker Label Audit"
description: "Audit Docker and Compose labels that drive external automation across the fleet and inside each stack."
---
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.
Sencho surfaces two read-only audit views for these labels:
- **Fleet · Docker Labels** for estate-wide visibility across every node
- **Stack · Compose Labels** for per-stack reconciliation between declared Compose labels and labels present on running containers
## Fleet Docker Labels tab
Open **Fleet** and select the **Docker Labels** tab (after **Map**). The panel is titled **Docker label audit** and offers two layouts:
| View | What it shows |
|------|----------------|
| **By container** | Each container with its label count. Expand a row to see every key, value, and provenance badge. |
| **By label** | Each unique `key=value` pair and every container that carries it, with node name chips on multi-node fleets. |
Use the search box to filter by label key, value, container name, stack, or node. When a container belongs to a known stack, **Open stack** jumps to that stack in the editor.
Every label carries a provenance badge so you can tell where it came from:
- **Image** for labels inherited from the container image (for example OCI `org.opencontainers.image.*` metadata)
- **Present at runtime** for other labels set on the running container
- **Docker Compose system label** for keys starting with `com.docker.compose.`
- **Unknown** when a container or its image could not be inspected
The fleet view reads container-level metadata and does not open the compose file, so it cannot tell a Compose-declared label from any other label set on the container: labels you declared in Compose appear here as **Present at runtime**. To see which labels come from the compose file, use the per-stack **Compose Labels** tab below. When a container or its image cannot be inspected, the affected labels show as **Unknown** and the panel names the nodes it could not fully inspect.
In the **By label** layout, open the **Filters** popover next to the search box. The **Defined by** section lists toggle pills for each provenance type present in the data (**Image**, **Runtime**, **System**, and **Unknown** when present). Turn a pill off to hide that source; the button shows a count badge while any filter is active, and **Clear filters** resets the popover. Automation tools such as Watchtower, Diun, and Traefik read these labels, so the audit makes it easy to confirm which containers are opted in or out.
Runtime labels are static until the container is recreated. Changes declared in Compose require save and redeploy before they appear on running containers.
## Stack Compose Labels tab
Inside the stack editor, open the **Compose Labels** tab in the anatomy strip. For each service you see:
- **Declared in Compose** labels from the effective rendered compose model
- **Present at runtime** labels read from each running replica, each with its provenance badge (Compose, Image, or runtime)
- Reconciliation hints: **only in Compose**, **only on running container**, **present in both**, or **value changed** when a key is declared and running but the values differ
Because this tab renders the compose model, it can identify Compose-declared labels accurately. A toolbar at the top combines a **search box** with a **Filters** popover. The search matches label keys and values as well as service and container names. The popover has a **Defined by** section with toggle pills for each provenance present (**Compose File**, **Image**, **Runtime**, **System**) and a **Services** section to show or hide individual service cards when the stack has more than one service. The Filters button shows a count badge for active facet and service filters; **Clear filters** resets both sections. Matching a service or container name reveals that parent's labels even when the text does not match a specific key or value. Reconciliation counts always reflect only the labels currently visible.
When Compose cannot be fully rendered, the panel warns that declared labels may be incomplete but still shows whatever runtime data is available. If a replica cannot be inspected, it is flagged with **Runtime labels unavailable** and the panel notes that provenance may be incomplete.
## Sensitive values
Some label keys look like secrets (for example keys containing `token`, `password`, or `auth`). Their values are redacted by default. Admins can reveal full values with the **Reveal** control, which re-fetches the inventory with elevated read access.
## Editing labels
Compose label editing from these panels is not available yet. To change labels today, edit the compose file directly and redeploy the stack.
+1
View File
@@ -80,6 +80,7 @@ The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and
| **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). |
| **Drift** | Yes | Live comparison of the declared compose against the running containers. See [Stack Drift](/features/stack-drift). |
| **Environment** | When `env-inventory` capability is present | Variable inventory across all env files, with status for each variable. See [Environment Guardrails](/features/environment-guardrails). |
| **Compose Labels** | When `container-label-inventory` capability is present | Declared Compose labels vs runtime container labels per service. See [Docker Label Audit](/features/docker-label-audit). |
| **Networking** | When `compose-networking` capability is present | Port exposure summary per service with intent classification. See [Compose Networking](/features/compose-networking). |
| **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). |
| **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). |
+243
View File
@@ -149,6 +149,148 @@ components:
type: boolean
example: true
LabelSource:
type: string
description: Provenance of a label. `unknown` when a container or image could not be inspected.
enum: [compose, runtime, image, compose-system, unknown]
LabelValue:
type: object
required: [key, value, source]
properties:
key: { type: string }
value:
type: string
description: Redacted to `[redacted]` for secret-like keys unless the caller is an admin and passes `reveal=1`.
source: { $ref: "#/components/schemas/LabelSource" }
redacted: { type: boolean }
LabelIndexContainerRef:
type: object
required: [id, name, stack, service]
properties:
id: { type: string }
name: { type: string }
stack: { type: string, nullable: true }
service: { type: string, nullable: true }
nodeId: { type: integer }
nodeName: { type: string }
LabelIndexRow:
type: object
description: One unique key/value/source and every container carrying it.
required: [key, value, source, containers]
properties:
key: { type: string }
value: { type: string }
source: { $ref: "#/components/schemas/LabelSource" }
redacted: { type: boolean }
containers:
type: array
items: { $ref: "#/components/schemas/LabelIndexContainerRef" }
ContainerLabelRow:
type: object
required: [id, name, stack, service, state, labels]
properties:
id: { type: string }
name: { type: string }
stack: { type: string, nullable: true }
service: { type: string, nullable: true }
state: { type: string }
labels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
StackLabelReplica:
type: object
required: [id, name, state, runtimeLabels, onlyInCompose, onlyOnContainer, inBoth, changed]
properties:
id: { type: string }
name: { type: string }
state: { type: string }
runtimeLabels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
onlyInCompose: { type: array, items: { type: string } }
onlyOnContainer: { type: array, items: { type: string } }
inBoth: { type: array, items: { type: string } }
changed:
type: array
items: { type: string }
description: Keys declared in Compose and present at runtime but with a different value.
inspectFailed:
type: boolean
description: Runtime labels could not be read for this replica; reconciliation was skipped.
StackServiceLabelRow:
type: object
required: [service, declaredLabels, replicas]
properties:
service: { type: string }
declaredLabels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
replicas:
type: array
items: { $ref: "#/components/schemas/StackLabelReplica" }
StackLabelInventory:
type: object
required: [stackName, renderable, services, partial, generatedAt]
properties:
stackName: { type: string }
renderable:
type: boolean
description: False when the Compose model could not be rendered; declared provenance is then unknown.
partial:
type: boolean
description: A replica or its image could not be fully inspected.
generatedAt: { type: integer }
services:
type: array
items: { $ref: "#/components/schemas/StackServiceLabelRow" }
NodeLabelInventory:
type: object
required: [nodeId, containers, byLabel, partial, generatedAt]
properties:
nodeId: { type: integer }
partial: { type: boolean }
generatedAt: { type: integer }
containers:
type: array
items: { $ref: "#/components/schemas/ContainerLabelRow" }
byLabel:
type: array
items: { $ref: "#/components/schemas/LabelIndexRow" }
FleetLabelInventory:
type: object
required: [nodes, aggregatedByLabel, nodeErrors, generatedAt]
properties:
generatedAt: { type: integer }
nodes:
type: array
items:
type: object
required: [nodeId, nodeName, status, inventory, error]
properties:
nodeId: { type: integer }
nodeName: { type: string }
status: { type: string, enum: [ok, error] }
inventory:
nullable: true
allOf: [{ $ref: "#/components/schemas/NodeLabelInventory" }]
error: { type: string, nullable: true }
aggregatedByLabel:
type: array
items: { $ref: "#/components/schemas/LabelIndexRow" }
nodeErrors:
type: object
additionalProperties: { type: string }
description: Map of node id to error for nodes that were unreachable or returned a malformed payload.
FailureClassification:
type: object
description: Classified cause of a failed deploy or update, with a suggested next step.
@@ -947,6 +1089,107 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/label-inventory:
get:
operationId: getStackLabelInventory
tags: [Stacks]
summary: Get Docker label inventory for a stack
description: >-
Returns declared Compose labels and runtime container labels per service,
with reconciliation hints (only in Compose, only on running container,
present in both, or value changed when the values differ) and provenance
for each runtime label (compose, image, runtime, compose-system, or unknown
when a container or image cannot be inspected). Secret-like label values are
redacted unless the caller is an admin and passes `reveal=1`. Requires
`stack:read` permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Stack label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/StackLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"404":
description: Stack not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
/api/system/container-labels:
get:
operationId: getNodeContainerLabels
tags: [Fleet]
summary: Get the Docker label inventory for the active node
description: >-
Returns every container on the node with its labels and provenance
(compose, image, runtime, compose-system, or unknown), plus an inverted
`byLabel` index. Marks `partial` when a container or image inspect fails.
Secret-like values are redacted unless the caller is an admin and passes
`reveal=1`. Requires `node:read` permission.
parameters:
- $ref: "#/components/parameters/nodeId"
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Node container label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/NodeLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/fleet/container-labels:
get:
operationId: getFleetContainerLabels
tags: [Fleet]
summary: Get the Docker label inventory aggregated across the fleet
description: >-
Fans out to every node's container label inventory and aggregates the
inverted index by key, value, and source. Unreachable or malformed nodes
degrade into `nodeErrors` rather than failing the whole request. Secret-like
values are redacted unless the caller is an admin and passes `reveal=1`.
Requires `node:read` permission.
parameters:
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Fleet-wide container label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/FleetLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/env:
get:
operationId: getStackEnv
+17 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import {
RefreshCw, Camera, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
} from 'lucide-react';
import { FleetMasthead } from './fleet/FleetMasthead';
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
import { springs } from '@/lib/motion';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
@@ -29,6 +30,7 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { ContainerLabelsTab } from './fleet/ContainerLabelsTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
@@ -49,6 +51,8 @@ interface FleetViewProps {
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
const containerLabelsEnabled = hasCapability('container-label-inventory');
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
@@ -132,6 +136,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
<Workflow className="w-4 h-4 mr-1.5" />Map
</TabsTrigger>
</TabsHighlightItem>
{containerLabelsEnabled && (
<TabsHighlightItem value="container-labels">
<TabsTrigger value="container-labels">
<Tag className="w-4 h-4 mr-1.5" />Docker Labels
</TabsTrigger>
</TabsHighlightItem>
)}
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="deployments">
@@ -245,6 +256,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
<TabsContent value="dependencies">
<DependencyMapTab />
</TabsContent>
{containerLabelsEnabled && (
<TabsContent value="container-labels">
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
</TabsContent>
)}
{isPaid && (
<TabsContent value="deployments">
<DeploymentsTab />
@@ -15,6 +15,7 @@ import DriftPanel from './stack/DriftPanel';
import PreflightPanel from './stack/PreflightPanel';
import StoragePanel from './stack/StoragePanel';
import EnvironmentPanel from './stack/EnvironmentPanel';
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
import StackNetworkingPanel from './stack/StackNetworkingPanel';
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from '@/components/dashboard/types';
@@ -101,6 +102,7 @@ export default function StackAnatomyPanel({
const networkingEnabled = hasCapability('compose-networking');
const storageEnabled = hasCapability('compose-storage');
const envInventoryEnabled = hasCapability('env-inventory');
const composeLabelsEnabled = hasCapability('container-label-inventory');
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null);
// Merged effective facts (services/ports/volumes/networks/restart) for a
@@ -370,6 +372,9 @@ export default function StackAnatomyPanel({
{envInventoryEnabled && (
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
)}
{composeLabelsEnabled && (
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
)}
{networkingEnabled && (
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
)}
@@ -623,6 +628,11 @@ export default function StackAnatomyPanel({
<EnvironmentPanel stackName={stackName} />
</TabsContent>
)}
{composeLabelsEnabled && (
<TabsContent value="compose-labels" className="flex flex-col flex-1 min-h-0 mt-0">
<ComposeLabelsPanel stackName={stackName} />
</TabsContent>
)}
{doctorEnabled && (
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
<PreflightPanel stackName={stackName} />
@@ -0,0 +1,380 @@
import { useCallback, useEffect, useMemo, useState, Fragment } from 'react';
import { ChevronDown, ChevronRight, ExternalLink, Lock, RefreshCw, Search, SlidersHorizontal } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/context/AuthContext';
import {
LABEL_DISAMBIGUATION_COPY,
SOURCE_LABELS,
SOURCE_FACET_LABELS,
matchesSearch,
sourcesPresent,
type ContainerLabelRow,
type FleetLabelInventoryResponse,
type LabelIndexRow,
type LabelSource,
type LabelValue,
} from '@/lib/labelInventory';
type ViewMode = 'container' | 'label';
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
interface ContainerLabelsTabProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
}
function LabelValueCell({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
const { isAdmin } = useAuth();
return (
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/90">
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
<span>{label.value}</span>
{label.redacted && isAdmin && onReveal && (
<button
type="button"
onClick={onReveal}
className="font-mono text-[10px] uppercase tracking-wide text-brand hover:underline"
>
Reveal
</button>
)}
</span>
);
}
export function ContainerLabelsTab({ onNavigateToNode }: ContainerLabelsTabProps) {
const { isAdmin } = useAuth();
const [viewMode, setViewMode] = useState<ViewMode>('container');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [revealSecrets, setRevealSecrets] = useState(false);
const [data, setData] = useState<FleetLabelInventoryResponse | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
setLoading(true);
try {
const qs = reveal ? '?reveal=1' : '';
const res = await apiFetch(`/fleet/container-labels${qs}`, { localOnly: true });
if (!res.ok) throw new Error('Failed to load Docker label audit');
setData(await res.json() as FleetLabelInventoryResponse);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load Docker label audit';
toast.error(msg);
setData(null);
} finally {
setLoading(false);
}
}, [revealSecrets]);
useEffect(() => {
void fetchInventory();
}, [fetchInventory]);
const allContainers = useMemo(() => {
if (!data) return [] as Array<ContainerLabelRow & { nodeId: number; nodeName: string }>;
const rows: Array<ContainerLabelRow & { nodeId: number; nodeName: string }> = [];
for (const node of data.nodes) {
if (node.status !== 'ok' || !node.inventory) continue;
for (const c of node.inventory.containers) {
rows.push({ ...c, nodeId: node.nodeId, nodeName: node.nodeName });
}
}
return rows;
}, [data]);
const filteredContainers = useMemo(() => {
return allContainers.filter(c =>
matchesSearch(search, c.name, c.stack, c.service, c.state, c.nodeName)
|| c.labels.some(l => matchesSearch(search, l.key, l.value)),
);
}, [allContainers, search]);
// Sources present across the aggregated index, for the data-driven facet row. Derived from
// the unfiltered data so toggling a facet off never removes the facet itself.
const labelSourceFacets = useMemo(
() => sourcesPresent((data?.aggregatedByLabel ?? []).map(r => r.source)),
[data],
);
const filteredByLabel = useMemo(() => {
const source = data?.aggregatedByLabel ?? [];
return source.filter(row =>
!excludedSources.has(row.source)
&& (matchesSearch(search, row.key, row.value)
|| row.containers.some(c => matchesSearch(search, c.name, c.stack, c.nodeName))),
);
}, [data, search, excludedSources]);
const toggleSource = (s: LabelSource) => {
setExcludedSources(prev => {
const next = new Set(prev);
if (next.has(s)) next.delete(s); else next.add(s);
return next;
});
};
// Nodes that were unreachable (nodeErrors) or whose inventory came back partial (some
// containers or images could not be inspected). Named so the warning is truthful.
const degradedNodes = useMemo(() => {
const unreachable: string[] = [];
const partial: string[] = [];
if (data) {
const nameById = new Map(data.nodes.map(n => [n.nodeId, n.nodeName] as const));
for (const idStr of Object.keys(data.nodeErrors)) {
unreachable.push(nameById.get(Number(idStr)) ?? `node ${idStr}`);
}
for (const n of data.nodes) {
if (n.status === 'ok' && n.inventory?.partial) partial.push(n.nodeName);
}
}
return { unreachable, partial };
}, [data]);
const toggleExpanded = (id: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleReveal = () => {
setRevealSecrets(true);
void fetchInventory(true);
};
return (
<div className="space-y-4" data-testid="container-labels-tab">
<p className="text-xs text-stat-subtitle leading-relaxed max-w-3xl">{LABEL_DISAMBIGUATION_COPY}</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Docker label audit</p>
<SegmentedControl
className="mt-2"
value={viewMode}
onChange={(v) => setViewMode(v as ViewMode)}
options={[
{ value: 'container', label: 'By container' },
{ value: 'label', label: 'By label' },
]}
/>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter labels, containers, stacks..."
className="h-9 w-56 pl-8 max-md:w-full"
/>
</div>
{viewMode === 'label' && labelSourceFacets.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant={excludedSources.size > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
<SlidersHorizontal className="w-4 h-4" />
Filters
{excludedSources.size > 0 && (
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{excludedSources.size}</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 space-y-4">
<div className="space-y-1.5">
<label className={FILTER_SECTION_LABEL_CLASS}>Defined by</label>
<div className="flex flex-wrap items-center gap-1.5">
{labelSourceFacets.map((s) => (
<Button
key={s}
variant={!excludedSources.has(s) ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
aria-pressed={!excludedSources.has(s)}
onClick={() => toggleSource(s)}
>
{SOURCE_FACET_LABELS[s]}
</Button>
))}
</div>
</div>
{excludedSources.size > 0 && (
<Button variant="ghost" size="sm" className="w-full h-8 text-xs" onClick={() => setExcludedSources(new Set())}>
Clear filters
</Button>
)}
</PopoverContent>
</Popover>
)}
<Button variant="outline" size="sm" className="h-9 w-9 p-0" onClick={() => void fetchInventory()} disabled={loading} aria-label="Refresh">
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
</div>
{loading && !data && (
<p className="text-sm text-stat-subtitle">Loading Docker label audit...</p>
)}
{!loading && data && (degradedNodes.unreachable.length > 0 || degradedNodes.partial.length > 0) && (
<p className="text-xs text-warning">
{degradedNodes.unreachable.length > 0 && `Could not reach ${degradedNodes.unreachable.join(', ')}. `}
{degradedNodes.partial.length > 0 && `Some containers or images could not be inspected on ${degradedNodes.partial.join(', ')}. `}
Showing partial fleet data.
</p>
)}
{viewMode === 'container' && (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Container</TableHead>
<TableHead>Stack</TableHead>
<TableHead>Node</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Labels</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filteredContainers.map((c) => {
const rowKey = `${c.nodeId}:${c.id}`;
const isOpen = expanded.has(rowKey);
return (
<Fragment key={rowKey}>
<TableRow className="hover:bg-muted/30">
<TableCell>
<button type="button" onClick={() => toggleExpanded(rowKey)} className="text-muted-foreground">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</TableCell>
<TableCell className="font-mono text-xs">{c.name || c.id.slice(0, 12)}</TableCell>
<TableCell className="text-xs">{c.stack ?? '—'}</TableCell>
<TableCell className="text-xs">{c.nodeName}</TableCell>
<TableCell className="text-xs">{c.state}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{c.labels.length}</TableCell>
<TableCell className="text-right">
{c.stack && (
<Button variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => onNavigateToNode(c.nodeId, c.stack!)}>
<ExternalLink className="h-3 w-3 mr-1" /> Open stack
</Button>
)}
</TableCell>
</TableRow>
{isOpen && (
<TableRow key={`${rowKey}-detail`}>
<TableCell colSpan={7} className="bg-card/40 p-0">
<Table>
<TableBody>
{c.labels.map((label) => (
<TableRow key={`${rowKey}-${label.key}`}>
<TableCell className="font-mono text-xs text-muted-foreground w-[40%]">{label.key}</TableCell>
<TableCell>
<LabelValueCell label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
</TableCell>
<TableCell className="text-[10px] text-stat-subtitle">{SOURCE_LABELS[label.source]}</TableCell>
</TableRow>
))}
{c.labels.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-xs text-stat-subtitle py-3">No labels on this container.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
{filteredContainers.length === 0 && !loading && (
<TableRow>
<TableCell colSpan={7} className="text-sm text-stat-subtitle py-6 text-center">No containers match this filter.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{viewMode === 'label' && (
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
Fleet shows container-level provenance and cannot distinguish Compose-declared labels from other container-set labels, so those appear as "Present at runtime".
</p>
)}
{viewMode === 'label' && (
<div className="space-y-3">
{filteredByLabel.map((row) => (
<LabelGroupCard key={`${row.key}=${row.value}=${row.source}`} row={row} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} onNavigateToNode={onNavigateToNode} />
))}
{filteredByLabel.length === 0 && !loading && (
<p className="text-sm text-stat-subtitle py-4 text-center">No labels match this filter.</p>
)}
</div>
)}
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
Runtime labels are static until the container is recreated. Changes in Compose require save and redeploy.
</p>
</div>
);
}
function LabelGroupCard({
row,
onReveal,
onNavigateToNode,
}: {
row: LabelIndexRow;
onReveal?: () => void;
onNavigateToNode: (nodeId: number, stackName: string) => void;
}) {
return (
<div className="rounded-lg border border-card-border bg-card/40 p-3">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="font-mono text-xs font-medium">{row.key}</span>
<span className="text-muted-foreground">=</span>
<LabelValueCell label={{ key: row.key, value: row.value, source: row.source, redacted: row.redacted }} onReveal={onReveal} />
<Badge variant="outline" className="text-[10px]">{SOURCE_LABELS[row.source]}</Badge>
</div>
<ul className="space-y-1">
{row.containers.map((c) => (
<li key={`${c.nodeId ?? 'local'}:${c.id}`} className="flex flex-wrap items-center gap-2 text-xs text-stat-subtitle">
<span className="font-mono text-foreground/80">{c.name}</span>
{c.nodeName && <Badge variant="secondary" className="text-[10px] h-5">{c.nodeName}</Badge>}
{c.stack && (
<>
<span>· {c.stack}</span>
{c.nodeId !== undefined && (
<button
type="button"
className="text-brand hover:underline font-mono text-[10px] uppercase"
onClick={() => onNavigateToNode(c.nodeId!, c.stack!)}
>
Open stack
</button>
)}
</>
)}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContainerLabelsTab } from '../ContainerLabelsTab';
import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
import { apiFetch } from '@/lib/api';
const mockFleet = {
nodes: [
{
nodeId: 1,
nodeName: 'Local',
status: 'ok' as const,
error: null,
inventory: {
nodeId: 1,
partial: false,
generatedAt: Date.now(),
containers: [
{
id: 'c1',
name: 'web-1',
stack: 'demo',
service: 'web',
state: 'running',
labels: [{ key: 'traefik.enable', value: 'true', source: 'runtime' as const }],
},
],
byLabel: [],
},
},
],
aggregatedByLabel: [
{
key: 'traefik.enable',
value: 'true',
source: 'runtime' as const,
containers: [{ id: 'c1', name: 'web-1', stack: 'demo', service: 'web', nodeId: 1, nodeName: 'Local' }],
},
],
nodeErrors: {},
generatedAt: Date.now(),
};
// Two rows sharing the same key=value but distinct sources (image vs runtime).
const dupMock = {
nodes: [{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: false, generatedAt: Date.now(), containers: [], byLabel: [] } }],
aggregatedByLabel: [
{ key: 'dup.label', value: 'v', source: 'image' as const, containers: [{ id: 'c1', name: 'a-1', stack: 's', service: 'a', nodeId: 1, nodeName: 'Local' }] },
{ key: 'dup.label', value: 'v', source: 'runtime' as const, containers: [{ id: 'c2', name: 'b-1', stack: 's', service: 'b', nodeId: 1, nodeName: 'Local' }] },
],
nodeErrors: {},
generatedAt: Date.now(),
};
describe('ContainerLabelsTab', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => mockFleet,
} as Response);
});
it('renders audit sections and toggles view mode', async () => {
const user = userEvent.setup();
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
expect(screen.getByText('Docker label audit')).toBeInTheDocument();
expect(await screen.findByText('web-1')).toBeInTheDocument();
await user.click(screen.getByText('By label'));
expect(await screen.findByText('traefik.enable')).toBeInTheDocument();
});
it('keeps the same key=value distinct per source with its own badge', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
await user.click(await screen.findByText('By label'));
expect(screen.getAllByText('dup.label')).toHaveLength(2);
// The misleading "External automation label" marker on non-Compose keys was removed.
expect(screen.queryByText(/External automation label/)).toBeNull();
});
it('filters the by-label list by search text', async () => {
const user = userEvent.setup();
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
await user.click(await screen.findByText('By label'));
expect(screen.getByText('traefik.enable')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
expect(screen.queryByText('traefik.enable')).toBeNull();
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
});
it('filters the by-container list by search text', async () => {
const user = userEvent.setup();
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
expect(await screen.findByText('web-1')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
expect(screen.queryByText('web-1')).toBeNull();
expect(screen.getByText('No containers match this filter.')).toBeInTheDocument();
});
it('exposes a facet in the Filters popover only for sources present, with exact labels', async () => {
const user = userEvent.setup();
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
await user.click(await screen.findByText('By label'));
await user.click(screen.getByRole('button', { name: /Filters/ }));
// mockFleet has only a runtime source.
expect(screen.getByRole('button', { name: 'Runtime' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Image' })).toBeNull();
});
it('filters the by-label list by source facet, hiding an unpressed source', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
await user.click(await screen.findByText('By label'));
expect(screen.getAllByText('dup.label')).toHaveLength(2);
await user.click(screen.getByRole('button', { name: /Filters/ }));
const imageBtn = screen.getByRole('button', { name: 'Image' });
expect(imageBtn).toHaveAttribute('aria-pressed', 'true');
await user.click(imageBtn);
expect(screen.getAllByText('dup.label')).toHaveLength(1);
// The facet stays present (just unpressed) because facets derive from unfiltered data.
expect(screen.getByRole('button', { name: 'Image' })).toHaveAttribute('aria-pressed', 'false');
});
it('shows the empty state when every source facet is turned off', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
await user.click(await screen.findByText('By label'));
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'Image' }));
await user.click(screen.getByRole('button', { name: 'Runtime' }));
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
});
it('names unreachable and partial nodes in the warning', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
nodes: [
{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: true, generatedAt: Date.now(), containers: [], byLabel: [] } },
{ nodeId: 2, nodeName: 'Edge', status: 'error' as const, error: 'boom', inventory: null },
],
aggregatedByLabel: [],
nodeErrors: { 2: 'boom' },
generatedAt: Date.now(),
}),
} as Response);
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
expect(await screen.findByText(/Could not reach Edge/)).toBeInTheDocument();
expect(screen.getByText(/could not be inspected on Local/)).toBeInTheDocument();
});
});
@@ -0,0 +1,365 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Info, Lock, Search, SlidersHorizontal } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAuth } from '@/context/AuthContext';
import {
LABEL_DISAMBIGUATION_COPY,
SOURCE_LABELS,
SOURCE_FACET_LABELS,
matchesSearch,
sourcesPresent,
type LabelSource,
type LabelValue,
type StackLabelInventory,
} from '@/lib/labelInventory';
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const CARD_CLASS = 'rounded-lg border border-muted px-3 py-2.5';
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
/**
* A label is visible when its source is not excluded AND either the text matches its
* key/value or the parent matched the search (a service by name, a replica by name or id).
* Parent matches bypass text matching only, never the source facets.
*/
function labelVisible(label: LabelValue, search: string, excluded: Set<LabelSource>, parentHit: boolean): boolean {
if (excluded.has(label.source)) return false;
return parentHit || matchesSearch(search, label.key, label.value);
}
function LabelRow({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
const { isAdmin } = useAuth();
return (
<div className="flex flex-wrap items-center gap-2 border-t border-muted py-2 first:border-t-0" data-testid="compose-label-row">
<span className="font-mono text-[12px] font-medium text-foreground/90">{label.key}</span>
<span className="text-muted-foreground">=</span>
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/80">
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
{label.value}
</span>
<span className="rounded border border-muted px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle">
{SOURCE_LABELS[label.source]}
</span>
{label.redacted && isAdmin && onReveal && (
<button type="button" onClick={onReveal} className="font-mono text-[10px] uppercase text-brand hover:underline">
Reveal
</button>
)}
</div>
);
}
function MismatchBadge({ kind, count }: { kind: 'only-compose' | 'only-container' | 'both' | 'changed'; count: number }) {
const labels = {
'only-compose': 'only in Compose',
'only-container': 'only on running container',
both: 'present in both',
changed: 'value changed',
};
const tones = {
'only-compose': 'border-warning/40 bg-warning/[0.06] text-warning',
'only-container': 'border-info/40 bg-info/[0.06] text-info',
both: 'border-muted bg-card/40 text-stat-subtitle',
changed: 'border-warning/40 bg-warning/[0.06] text-warning',
};
if (count === 0) return null;
return (
<span className={cn('inline-flex rounded border px-1.5 py-0.5 font-mono text-[10px]', tones[kind])} data-testid={`mismatch-${kind}`}>
{count} {labels[kind]}
</span>
);
}
export default function ComposeLabelsPanel({ stackName }: { stackName: string }) {
const { isAdmin } = useAuth();
const [inventory, setInventory] = useState<StackLabelInventory | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [revealSecrets, setRevealSecrets] = useState(false);
const [search, setSearch] = useState('');
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
const [hiddenServices, setHiddenServices] = useState<Set<string>>(new Set());
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
setLoading(true);
setLoadError(false);
try {
const qs = reveal ? '?reveal=1' : '';
const res = await apiFetch(`/stacks/${stackName}/label-inventory${qs}`);
if (!res.ok) {
setLoadError(true);
throw new Error('Failed to load Compose label inventory');
}
setInventory(await res.json() as StackLabelInventory);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load Compose label inventory');
setInventory(null);
} finally {
setLoading(false);
}
}, [stackName, revealSecrets]);
useEffect(() => {
void fetchInventory();
}, [fetchInventory]);
// Reset filters when the stack changes so selections never leak between stacks. Not keyed on
// reveal/refetch, so filters are preserved across a reveal on the same stack.
useEffect(() => {
setSearch('');
setExcludedSources(new Set());
setHiddenServices(new Set());
}, [stackName]);
const filtersActive = search.trim() !== '' || excludedSources.size > 0;
// Count of active facet filters for the Filters popover badge (search is its own control).
const filterCount = excludedSources.size + hiddenServices.size;
const sourceFacets = useMemo(() => {
const srcs: LabelSource[] = [];
for (const svc of inventory?.services ?? []) {
for (const l of svc.declaredLabels) srcs.push(l.source);
for (const rep of svc.replicas) for (const l of rep.runtimeLabels) srcs.push(l.source);
}
return sourcesPresent(srcs);
}, [inventory]);
const serviceNames = useMemo(() => (inventory?.services ?? []).map(s => s.service), [inventory]);
// Filter-aware view model: computed before render so badges reflect only visible labels and
// empty/failure states stay truthful. Per-service checkbox is the only thing that hides a card.
const serviceViews = useMemo(() => {
return (inventory?.services ?? [])
.filter(svc => !hiddenServices.has(svc.service))
.map(svc => {
const serviceHit = matchesSearch(search, svc.service);
const declaredVisible = svc.declaredLabels.filter(l => labelVisible(l, search, excludedSources, serviceHit));
const declaredVisibleKeys = new Set(declaredVisible.map(l => l.key));
const replicas = svc.replicas.map(rep => {
if (rep.inspectFailed) {
return { rep, inspectFailed: true as const, runtimeVisible: [] as LabelValue[], badges: { onlyInCompose: 0, onlyOnContainer: 0, inBoth: 0, changed: 0 }, render: true };
}
const replicaHit = serviceHit || matchesSearch(search, rep.name, rep.id);
const runtimeVisible = rep.runtimeLabels.filter(l => labelVisible(l, search, excludedSources, replicaHit));
const rtKeys = new Set(runtimeVisible.map(l => l.key));
const badges = {
onlyInCompose: rep.onlyInCompose.filter(k => declaredVisibleKeys.has(k)).length,
onlyOnContainer: rep.onlyOnContainer.filter(k => rtKeys.has(k)).length,
inBoth: rep.inBoth.filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
changed: (rep.changed ?? []).filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
};
const anyBadge = badges.onlyInCompose + badges.onlyOnContainer + badges.inBoth + badges.changed > 0;
const render = runtimeVisible.length > 0 || anyBadge || !filtersActive;
return { rep, inspectFailed: false as const, runtimeVisible, badges, render };
});
const renderedReplicas = replicas.filter(r => r.render);
const noRunning = svc.replicas.length === 0 && svc.declaredLabels.length > 0 && (!filtersActive || declaredVisible.length > 0);
const hasContent = declaredVisible.length > 0 || renderedReplicas.length > 0 || noRunning;
return { svc, declaredVisible, replicas: renderedReplicas, noRunning, hasContent };
});
}, [inventory, hiddenServices, search, excludedSources, filtersActive]);
const toggleSource = (s: LabelSource) => {
setExcludedSources(prev => {
const next = new Set(prev);
if (next.has(s)) next.delete(s); else next.add(s);
return next;
});
};
const toggleService = (s: string) => {
setHiddenServices(prev => {
const next = new Set(prev);
if (next.has(s)) next.delete(s); else next.add(s);
return next;
});
};
const handleReveal = () => {
setRevealSecrets(true);
void fetchInventory(true);
};
if (loading && !inventory) {
return <p className="px-3 py-3 font-mono text-[11px] text-stat-subtitle">Loading Compose labels</p>;
}
if (loadError) {
return <p className="px-3 py-3 font-mono text-[11px] text-destructive">Could not load Compose labels for this stack.</p>;
}
const noServices = (inventory?.services.length ?? 0) === 0;
const allServicesHidden = !noServices && serviceNames.every(s => hiddenServices.has(s));
return (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4" data-testid="compose-labels-panel">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>compose labels</span>
</div>
<p className="text-[11px] leading-relaxed text-stat-subtitle">{LABEL_DISAMBIGUATION_COPY}</p>
{!noServices && (
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter labels, services..."
className="h-9 pl-8"
data-testid="compose-label-search"
/>
</div>
{(sourceFacets.length > 0 || serviceNames.length > 1) && (
<Popover>
<PopoverTrigger asChild>
<Button variant={filterCount > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
<SlidersHorizontal className="w-4 h-4" />
Filters
{filterCount > 0 && (
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{filterCount}</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 space-y-4">
{sourceFacets.length > 0 && (
<div className="space-y-1.5">
<label className={FILTER_SECTION_LABEL_CLASS}>Defined by</label>
<div className="flex flex-wrap items-center gap-1.5">
{sourceFacets.map(s => (
<Button
key={s}
variant={!excludedSources.has(s) ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
aria-pressed={!excludedSources.has(s)}
onClick={() => toggleSource(s)}
>
{SOURCE_FACET_LABELS[s]}
</Button>
))}
</div>
</div>
)}
{serviceNames.length > 1 && (
<div className="space-y-1.5">
<label className={FILTER_SECTION_LABEL_CLASS}>Services</label>
<div className="flex flex-wrap items-center gap-1.5">
{serviceNames.map(name => (
<Button
key={name}
variant={!hiddenServices.has(name) ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5 font-mono"
aria-pressed={!hiddenServices.has(name)}
onClick={() => toggleService(name)}
>
{name}
</Button>
))}
</div>
</div>
)}
{filterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="w-full h-8 text-xs"
onClick={() => { setExcludedSources(new Set()); setHiddenServices(new Set()); }}
>
Clear filters
</Button>
)}
</PopoverContent>
</Popover>
)}
</div>
)}
{!inventory?.renderable && (
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>Compose could not be fully rendered. Declared labels may be incomplete.</span>
</div>
)}
{inventory?.partial && (
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>Some containers or images could not be inspected. Label provenance may be incomplete.</span>
</div>
)}
{noServices && (
<p className="text-sm text-stat-subtitle">No Compose or runtime labels found for this stack.</p>
)}
{allServicesHidden && (
<p className="text-sm text-stat-subtitle" data-testid="compose-labels-no-services">No services selected.</p>
)}
{serviceViews.map(({ svc, declaredVisible, replicas, noRunning, hasContent }) => (
<div key={svc.service} className={CARD_CLASS} data-testid="compose-label-service">
<p className={LABEL_CLASS}>{svc.service}</p>
{declaredVisible.length > 0 && (
<div className="mt-3">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">Declared in Compose</p>
{declaredVisible.map((label) => (
<LabelRow key={`decl-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
))}
</div>
)}
{replicas.map(({ rep, inspectFailed, runtimeVisible, badges }) => (
<div key={rep.id || rep.name} className="mt-3 border-t border-muted pt-3 first:mt-0 first:border-t-0 first:pt-0">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">
{rep.name || rep.id.slice(0, 12)} · {rep.state}
</p>
{inspectFailed ? (
<p className="text-xs text-warning">Runtime labels unavailable for this container.</p>
) : (
<>
{(badges.onlyInCompose > 0 || badges.onlyOnContainer > 0 || badges.changed > 0) && (
<div className="flex flex-wrap gap-1.5 mb-2">
<MismatchBadge kind="only-compose" count={badges.onlyInCompose} />
<MismatchBadge kind="changed" count={badges.changed} />
<MismatchBadge kind="only-container" count={badges.onlyOnContainer} />
<MismatchBadge kind="both" count={badges.inBoth} />
</div>
)}
{runtimeVisible.length > 0 ? (
runtimeVisible.map((label) => (
<LabelRow key={`rt-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
))
) : (
!filtersActive && <p className="text-xs text-stat-subtitle">No runtime labels on this container.</p>
)}
</>
)}
</div>
))}
{noRunning && (
<p className="mt-2 text-xs text-stat-subtitle">
No running containers for this service. Container may need redeploy for Compose label changes to apply.
</p>
)}
{filtersActive && !hasContent && (
<p className="mt-2 text-xs text-stat-subtitle" data-testid="compose-label-service-no-match">No labels match the filter.</p>
)}
</div>
))}
<p className="text-[10px] text-stat-subtitle leading-relaxed">
Runtime labels are static until the container is recreated. Container may need redeploy for Compose label changes to apply.
</p>
</div>
);
}
@@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ComposeLabelsPanel from '../ComposeLabelsPanel';
import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
import { apiFetch } from '@/lib/api';
const mockInventory = {
stackName: 'demo',
renderable: true,
generatedAt: Date.now(),
services: [
{
service: 'web',
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
replicas: [
{
id: 'c1',
name: 'demo-web-1',
state: 'running',
runtimeLabels: [
{ key: 'traefik.enable', value: 'true', source: 'runtime' as const },
{ key: 'runtime.only', value: '1', source: 'runtime' as const },
],
onlyInCompose: [],
onlyOnContainer: ['runtime.only'],
inBoth: ['traefik.enable'],
},
],
},
],
};
describe('ComposeLabelsPanel', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => mockInventory,
} as Response);
});
it('shows disambiguation copy and service labels', async () => {
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.getAllByText('traefik.enable')).toHaveLength(2);
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
expect(screen.getByTestId('mismatch-both')).toBeInTheDocument();
});
it('renders a value-changed badge when a label value drifted', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
});
it('shows "Runtime labels unavailable" for a replica whose inspect failed', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
inspectFailed: true,
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
expect(screen.getByText(/could not be inspected/i)).toBeInTheDocument();
});
// A service whose declared/runtime labels have distinctive keys so name-search can be isolated.
const searchMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'aaa.declared', value: '1', source: 'compose' as const }],
replicas: [{
id: 'w1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'zzz.runtime', value: 'q', source: 'runtime' as const }],
onlyInCompose: ['aaa.declared'], onlyOnContainer: ['zzz.runtime'], inBoth: [], changed: [],
}],
}],
};
it('service-name search exposes that service\'s declared and runtime labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'web');
expect(screen.getByText('aaa.declared')).toBeInTheDocument();
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
});
it('replica-name search exposes runtime labels only, not service-level declared labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'demo-web-1');
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
expect(screen.queryByText('aaa.declared')).toBeNull();
});
it('hides inBoth and changed badges when Compose File is filtered out', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web',
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
replicas: [{
id: 'c1', name: 'demo-web-1', state: 'running',
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
}],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(screen.queryByTestId('mismatch-changed')).toBeNull();
});
it('a source facet hides that source; turning off Compose File hides declared labels', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('aaa.declared')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(screen.queryByText('aaa.declared')).toBeNull();
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
});
it('recomputes badge counts from the visible set', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
// Search only the runtime key: onlyOnContainer stays 1, onlyInCompose drops to 0.
await user.type(await screen.findByTestId('compose-label-search'), 'zzz.runtime');
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
expect(screen.queryByTestId('mismatch-only-compose')).toBeNull();
});
it('shows the genuine "No runtime labels" for an empty replica when no filters are active', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }],
replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: ['a'], onlyOnContainer: [], inBoth: [], changed: [] }],
}],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('No runtime labels on this container.')).toBeInTheDocument();
});
it('keeps the inspectFailed warning under an active filter but hides it when the service is unchecked', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [], replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], inspectFailed: true }] },
{ service: 'db', declaredLabels: [{ key: 'x', value: '1', source: 'compose' as const }], replicas: [] },
],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
// Active text filter that matches nothing: the inspectFailed warning must still show.
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
expect(screen.getByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
// Hiding the web service (via the Filters popover) removes its card and the warning.
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'web' }));
expect(screen.queryByText('Runtime labels unavailable for this container.')).toBeNull();
});
it('distinguishes genuine-empty inventory from filtered-empty (no duplicate messages)', async () => {
const user = userEvent.setup();
// Genuine empty inventory.
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({ stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), services: [] }),
} as Response);
const { unmount } = render(<ComposeLabelsPanel stackName="demo" />);
expect(await screen.findByText('No Compose or runtime labels found for this stack.')).toBeInTheDocument();
unmount();
// Filtered-empty: one per-card message, no panel-level duplicate.
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
expect(screen.getByTestId('compose-label-service-no-match')).toBeInTheDocument();
expect(screen.queryByText('No Compose or runtime labels found for this stack.')).toBeNull();
});
it('shows "No services selected" when every service checkbox is unchecked', async () => {
const user = userEvent.setup();
vi.mocked(apiFetch).mockResolvedValue({
ok: true, json: async () => ({
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }], replicas: [] },
{ service: 'db', declaredLabels: [{ key: 'b', value: '2', source: 'compose' as const }], replicas: [] },
],
}),
} as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.click(await screen.findByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'web' }));
await user.click(screen.getByRole('button', { name: 'db' }));
expect(screen.getByTestId('compose-labels-no-services')).toBeInTheDocument();
});
it('preserves filters across a reveal on the same stack', async () => {
const user = userEvent.setup();
const revealMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [{
service: 'web', declaredLabels: [],
replicas: [{
id: 'w1', name: 'demo-web-1', state: 'running',
runtimeLabels: [
{ key: 'api.token', value: '[redacted]', source: 'runtime' as const, redacted: true },
{ key: 'plain.label', value: 'v', source: 'runtime' as const },
],
onlyInCompose: [], onlyOnContainer: ['api.token', 'plain.label'], inBoth: [], changed: [],
}],
}],
};
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => revealMock } as Response);
render(<ComposeLabelsPanel stackName="demo" />);
await user.type(await screen.findByTestId('compose-label-search'), 'api.token');
expect(screen.getByText('api.token')).toBeInTheDocument();
expect(screen.queryByText('plain.label')).toBeNull();
await user.click(screen.getByRole('button', { name: /reveal/i }));
// The reset effect is keyed on stackName only, so the reveal-driven refetch keeps the filter.
expect((screen.getByTestId('compose-label-search') as HTMLInputElement).value).toBe('api.token');
expect(screen.queryByText('plain.label')).toBeNull();
});
it('resets search, source, and service filters when the stack changes', async () => {
const user = userEvent.setup();
const twoServiceMock = {
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
services: [
{ service: 'web', declaredLabels: [{ key: 'web.owner', value: '1', source: 'compose' as const }], replicas: [] },
{ service: 'db', declaredLabels: [{ key: 'db.owner', value: '2', source: 'compose' as const }], replicas: [] },
],
};
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => twoServiceMock } as Response);
const { rerender } = render(<ComposeLabelsPanel stackName="demo" />);
const input = await screen.findByTestId('compose-label-search') as HTMLInputElement;
await user.type(input, 'zzz');
// Hide the db service and exclude the Compose File source via the Filters popover.
await user.click(screen.getByRole('button', { name: /Filters/ }));
await user.click(screen.getByRole('button', { name: 'db' }));
await user.click(screen.getByRole('button', { name: 'Compose File' }));
expect(input.value).toBe('zzz');
expect(screen.queryByText('web.owner')).toBeNull();
expect(screen.queryByText('db.owner')).toBeNull();
rerender(<ComposeLabelsPanel stackName="other" />);
// Filters reset: search cleared, both services and the source shown again.
expect((await screen.findByTestId('compose-label-search') as HTMLInputElement).value).toBe('');
expect(await screen.findByText('web.owner')).toBeInTheDocument();
expect(screen.getByText('db.owner')).toBeInTheDocument();
});
});
@@ -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));
}