feat: show container name in structured log output (#1452)

* feat: show container name in structured log output

Prepend a normalized container name prefix to each line in
ComposeService.streamLogs() so both the structured log viewer
and the raw terminal identify which container produced each entry.

- Backend: prepend displayName (normalized via normalizeContainerName)
  before LogFormatter.process() in sendOutput and flushBuffer.
- LogFormatter: refactor process() to handle both prefix-first and
  timestamp-first input orders via a while-loop; widen PREFIX_REGEX
  to accept dotted service names.
- Frontend: add containerName to LogRow, extract prefix in parseLine,
  render as an inline mono chip in the message column, and include
  the name in downloaded logs (omitting the bracket prefix when null).
- Tests: 14 new tests across log-formatter, compose-service streamLogs,
  and StructuredLogViewer chip rendering + download formatting.

* fix: guard LogFormatter loop to at most one prefix and one timestamp

The while-loop refactored for order-agnostic prefix/timestamp
parsing could continue matching beyond the intended single prefix
and timestamp. A log line like "redis | 2024-...Z api | started"
would falsely colorize "api |" as a second container prefix in
raw terminal output.

Add prefixFound/timestampFound boolean guards so the loop stops
after one prefix and one timestamp, regardless of input order.

* feat: per-service color alternation for log container chips

Add an Appearance setting that lets users switch between unified
cyan and per-service label-token colors for the container name chips
in the structured log viewer.

- Extract HUE_VARS and hashLabel() from NodeLabelPill into a shared
  utility at frontend/src/lib/label-colors.ts.
- Add useLogChipColorMode hook (browser-local localStorage,
  sencho.log-chip-color-mode key, unified by default).
- Add SegmentedControl in Settings > Appearance > Display.
- Apply inline label-token styles via style attribute in per-service
  mode; keep current text-brand/80 bg-brand/10 classes in unified mode.
- 14 new tests across label-colors, hook, and viewer chip rendering.
This commit is contained in:
Anso
2026-06-25 14:13:38 -04:00
committed by GitHub
parent ba1be3cc4e
commit f1f64ec7f6
12 changed files with 646 additions and 77 deletions
+127 -1
View File
@@ -111,7 +111,7 @@ vi.mock('../services/FileSystemService', () => ({
}));
vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
LogFormatter: { process: (line: string) => line },
}));
// runCommand and the deploy/update paths route through authoredComposeArgs, which
@@ -1056,3 +1056,129 @@ describe('ComposeService - idle-output stall backstop', () => {
expect(getComposeRollbackInfo(error)).toMatchObject({ attempted: true });
});
});
// ── streamLogs ─────────────────────────────────────────────────────────
describe('ComposeService - streamLogs', () => {
it('emits a normalized container name prefix for each container', async () => {
mockGetContainersByStack.mockResolvedValue([
{ Names: ['/mystack-redis-1'], State: 'running', Id: 'abc123' },
{ Names: ['/mystack-api-1'], State: 'running', Id: 'def456' },
]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const proc1 = createMockProcess();
const proc2 = createMockProcess();
mockSpawn
.mockReturnValueOnce(proc1)
.mockReturnValueOnce(proc2);
svc.streamLogs('mystack', ws);
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
// Emit stdout from each container.
proc1.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z redis log line\n'));
proc2.stdout.emit('data', Buffer.from('2024-01-01T00:00:01Z api response\n'));
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
expect(sentLines).toContain('redis | 2024-01-01T00:00:00Z redis log line');
expect(sentLines).toContain('api | 2024-01-01T00:00:01Z api response');
});
it('prefixes flushBuffer trailing line on child close', async () => {
mockGetContainersByStack.mockResolvedValue([
{ Names: ['/mystack-web-1'], State: 'running', Id: 'ghi789' },
]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
svc.streamLogs('mystack', ws);
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
// Emit a line without a trailing newline, then close.
proc.stdout.emit('data', Buffer.from('trailing content'));
proc.emit('close', 0);
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
expect(sentLines).toContain('web | trailing content');
});
it('joins chunk-split lines and prefixes once', async () => {
mockGetContainersByStack.mockResolvedValue([
{ Names: ['/mystack-db-1'], State: 'running', Id: 'jkl012' },
]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
svc.streamLogs('mystack', ws);
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
// Split a single line across two data events.
proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z partial '));
proc.stdout.emit('data', Buffer.from('end of line\n'));
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
expect(sentLines).toContain('db | 2024-01-01T00:00:00Z partial end of line');
});
it('normalizes dotted container names', async () => {
mockGetContainersByStack.mockResolvedValue([
{ Names: ['/mystack-api.v1-1'], State: 'running', Id: 'mno345' },
]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
svc.streamLogs('mystack', ws);
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z started\n'));
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
// normalizeContainerName strips stack prefix and -1 replica suffix, leaving 'api.v1'.
expect(sentLines).toContain('api.v1 | 2024-01-01T00:00:00Z started');
});
it('passes raw container name to docker logs, not normalized name', async () => {
mockGetContainersByStack.mockResolvedValue([
{ Names: ['/mystack-redis-1'], State: 'running', Id: 'pqr678' },
]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
svc.streamLogs('mystack', ws);
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
// Verify that docker logs uses the raw name, not the normalized one.
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['logs', '-f', '-t', '--tail', '100', 'mystack-redis-1'],
expect.anything(),
);
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { LogFormatter } from '../services/LogFormatter';
const CYAN = '\x1b[36m';
const GRAY = '\x1b[90m';
const RESET = '\x1b[0m';
const WHITE = '\x1b[37m';
describe('LogFormatter.process', () => {
it('colorizes prefix and timestamp when prefix comes first', () => {
const result = LogFormatter.process('redis | 2024-01-01T00:00:00Z started');
expect(result).toContain(`${CYAN}redis${WHITE}${RESET} | `);
expect(result).toContain(`${GRAY}2024-01-01T00:00:00Z ${RESET}`);
});
it('colorizes timestamp and prefix when timestamp comes first (legacy order)', () => {
const result = LogFormatter.process('2024-01-01T00:00:00Z redis | started');
expect(result).toContain(`${GRAY}2024-01-01T00:00:00Z ${RESET}`);
expect(result).toContain(`${CYAN}redis${WHITE}${RESET} | `);
});
it('colorizes timestamp only when no prefix is present (backward compat)', () => {
const result = LogFormatter.process('2024-01-01T00:00:00Z started');
expect(result).toContain(`${GRAY}2024-01-01T00:00:00Z ${RESET}`);
expect(result).not.toContain(CYAN);
});
it('colorizes dotted container names', () => {
const result = LogFormatter.process('api.v1 | 2024-01-01T00:00:00Z started');
expect(result).toContain(`${CYAN}api.v1${WHITE}${RESET} | `);
expect(result).toContain(`${GRAY}2024-01-01T00:00:00Z ${RESET}`);
});
it('colorizes container names with underscores', () => {
const result = LogFormatter.process('my-service_1 | 2024-01-01T00:00:00Z started');
expect(result).toContain(`${CYAN}my-service_1${WHITE}${RESET} | `);
expect(result).toContain(`${GRAY}2024-01-01T00:00:00Z ${RESET}`);
});
it('handles timestamp with offset notation', () => {
const result = LogFormatter.process('redis | 2024-01-01T00:00:00+05:00 started');
expect(result).toContain(`${CYAN}redis${WHITE}${RESET} | `);
expect(result).toContain(`${GRAY}2024-01-01T00:00:00+05:00 ${RESET}`);
});
it('returns empty string unchanged', () => {
expect(LogFormatter.process('')).toBe('');
});
it('returns whitespace-only string unchanged', () => {
expect(LogFormatter.process(' ')).toBe(' ');
});
it('handles bare message with no prefix or timestamp', () => {
const result = LogFormatter.process('just a plain message');
expect(result).toBe('just a plain message');
});
it('does not colorize a second "word | " in the message body as a prefix', () => {
const result = LogFormatter.process('redis | 2024-01-01T00:00:00Z api | started');
// The first "redis | " is the genuine container prefix.
expect(result).toContain(`${CYAN}redis${WHITE}${RESET} | `);
// The "api | " in the body must not be colorized as a second prefix.
const afterPrefix = result.split(' | ').slice(1).join(' | ');
expect(afterPrefix).not.toContain(CYAN);
});
});
+6 -4
View File
@@ -16,6 +16,7 @@ import { deriveStackExposure } from './preflight/exposure';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { normalizeContainerName } from '../utils/log-parsing';
import { describeSpawnError } from '../utils/spawnErrors';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
@@ -553,7 +554,8 @@ export class ComposeService {
};
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
const rawName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
const displayName = normalizeContainerName(rawName, stackName);
activeProcesses++;
let lineBuffer = '';
@@ -563,19 +565,19 @@ export class ComposeService {
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() || '';
for (const line of lines) {
ws.send(LogFormatter.process(line) + '\r\n');
ws.send(LogFormatter.process(`${displayName} | ${line}`) + '\r\n');
}
}
};
const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
ws.send(LogFormatter.process(`${displayName} | ${lineBuffer}`) + '\r\n');
lineBuffer = '';
}
};
const child = spawn('docker', ['logs', '-f', '-t', '--tail', '100', containerName], {
const child = spawn('docker', ['logs', '-f', '-t', '--tail', '100', rawName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
+36 -19
View File
@@ -13,7 +13,7 @@ export class LogFormatter {
private static readonly TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\s*)/;
// Matches docker-compose style prefix like "container-name | " or "db-1 | "
private static readonly PREFIX_REGEX = /^([a-zA-Z0-9_-]+)(?:\s+\|\s+)/;
private static readonly PREFIX_REGEX = /^([a-zA-Z0-9_.-]+)(?:\s+\|\s+)/;
// Level regexes (case insensitive for matching, but we check raw string for precise targeting if needed)
private static readonly ERROR_REGEX = /\b(ERROR|ERR|Exception|Fatal)\b/i;
@@ -26,26 +26,43 @@ export class LogFormatter {
let processedLine = line;
let formatAccumulator = '';
// 1. Process Timestamp
const tsMatch = processedLine.match(LogFormatter.TIMESTAMP_REGEX);
if (tsMatch) {
const ts = tsMatch[1];
formatAccumulator += `${LogFormatter.GRAY}${ts}${LogFormatter.RESET}`;
processedLine = processedLine.slice(ts.length);
// 1. Process at most one container-name prefix and one Docker
// timestamp in arrival order. Each regex is anchored at ^ and
// strips its match from the remainder. The prefixFound /
// timestampFound guards prevent false matches on legitimate
// log bodies that happen to contain "word | " later in the line.
let changed = true;
let prefixFound = false;
let timestampFound = false;
while (changed) {
changed = false;
if (!prefixFound) {
const prefixMatch = processedLine.match(LogFormatter.PREFIX_REGEX);
if (prefixMatch) {
const pfxMatchStr = prefixMatch[0]; // e.g. "redis | "
const name = prefixMatch[1];
const restOfPrefix = pfxMatchStr.slice(name.length); // e.g. " | "
formatAccumulator += `${LogFormatter.CYAN}${name}${LogFormatter.WHITE}${LogFormatter.RESET}${restOfPrefix}`;
processedLine = processedLine.slice(pfxMatchStr.length);
changed = true;
prefixFound = true;
}
}
if (!timestampFound) {
const tsMatch = processedLine.match(LogFormatter.TIMESTAMP_REGEX);
if (tsMatch) {
const ts = tsMatch[1];
formatAccumulator += `${LogFormatter.GRAY}${ts}${LogFormatter.RESET}`;
processedLine = processedLine.slice(ts.length);
changed = true;
timestampFound = true;
}
}
}
// 2. Process Prefix
const prefixMatch = processedLine.match(LogFormatter.PREFIX_REGEX);
if (prefixMatch) {
const pfxMatchStr = prefixMatch[0]; // e.g. "container-name | "
const name = prefixMatch[1];
const restOfPrefix = pfxMatchStr.slice(name.length); // e.g. " | "
formatAccumulator += `${LogFormatter.CYAN}${name}${LogFormatter.WHITE}${LogFormatter.RESET}${restOfPrefix}`;
processedLine = processedLine.slice(pfxMatchStr.length);
}
// 3. Process Levels & JSON
// 2. Process Levels & JSON
const trimmedLine = processedLine.trim();
// Fast JSON Check (Starts with { and ends with })
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Download, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { hashLabel } from '@/lib/label-colors';
interface StructuredLogViewerProps {
stackName: string;
@@ -14,6 +16,8 @@ interface LogRow {
ts: string | null;
level: LogLevel;
message: string;
/** Normalized service name extracted from the log prefix, or null for synthetic / old-format rows. */
containerName: string | null;
/** True when this row was synthesized by the client (e.g. reconnect sentinel). */
synthetic?: boolean;
}
@@ -24,6 +28,7 @@ const BUFFER_CAP = 10_000;
const TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)$/;
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g;
const PREFIX_REGEX = /^([a-zA-Z0-9_.-]+)(?:\s+\|\s+)/;
const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i;
const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i;
@@ -34,14 +39,20 @@ const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i;
const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
function parseLine(raw: string): Omit<LogRow, 'id'> {
const stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, '');
let stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, '');
let containerName: string | null = null;
const prefixMatch = stripped.match(PREFIX_REGEX);
if (prefixMatch) {
containerName = prefixMatch[1];
stripped = stripped.slice(prefixMatch[0].length);
}
const match = stripped.match(TIMESTAMP_REGEX);
const ts = match ? match[1] : null;
const body = match ? match[2] : stripped;
let level: LogLevel = 'info';
if (ERROR_REGEX.test(body)) level = 'err';
else if (WARN_REGEX.test(body)) level = 'warn';
return { ts, level, message: body };
return { ts, level, message: body, containerName };
}
function formatTs(iso: string | null): string {
@@ -59,6 +70,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
const [filter, setFilter] = useState<Filter>('all');
const [following, setFollowing] = useState(true);
const [connectionState, setConnectionState] = useState<'connecting' | 'open' | 'reconnecting'>('connecting');
const [chipColorMode] = useLogChipColorMode();
const scrollRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(true);
const rowIdRef = useRef(0);
@@ -107,6 +119,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
ts: new Date().toISOString(),
level,
message,
containerName: null,
synthetic: true,
});
scheduleFlush();
@@ -212,7 +225,11 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
};
const downloadLogs = () => {
const text = rows.map(r => `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()).join('\n');
const text = rows.map(r =>
r.containerName
? `[${r.containerName}] ${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()
: `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim(),
).join('\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -296,7 +313,29 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
)}>
{row.level}
</span>
<span className="whitespace-pre-wrap break-all text-foreground/90">{row.message}</span>
<span className="whitespace-pre-wrap break-all text-foreground/90">
{row.containerName && (
<span
className={cn(
'font-mono text-[10px] tracking-wide rounded px-1.5 py-px mr-1.5 select-none',
chipColorMode === 'per-service' ? 'border' : 'text-brand/80 bg-brand/10',
)}
title={row.containerName}
style={
chipColorMode === 'per-service'
? {
backgroundColor: `var(--label-${hashLabel(row.containerName)}-bg)`,
color: `var(--label-${hashLabel(row.containerName)})`,
borderColor: `color-mix(in oklch, var(--label-${hashLabel(row.containerName)}) 30%, transparent)`,
}
: undefined
}
>
{row.containerName}
</span>
)}
{row.message}
</span>
</div>
))
)}
@@ -1,12 +1,14 @@
/**
* Unit tests for StructuredLogViewer's log-row lifecycle, specifically that
* switching stacks clears the old stack's committed rows, closes the old
* WebSocket, resets auto-follow, and preserves the level filter.
* Unit tests for StructuredLogViewer's log-row lifecycle (stack switching,
* row clearing, auto-follow reset, level filter), container name chip
* rendering, and chip color mode (unified / per-service).
*/
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import StructuredLogViewer from '../StructuredLogViewer';
import { LOG_CHIP_COLOR_KEY } from '@/hooks/use-log-chip-color-mode';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
class MockWS {
static instances: MockWS[] = [];
@@ -30,6 +32,7 @@ beforeEach(() => {
return 0;
});
localStorage.setItem('sencho-active-node', '');
localStorage.removeItem(LOG_CHIP_COLOR_KEY);
});
afterEach(() => {
@@ -178,4 +181,157 @@ describe('StructuredLogViewer', () => {
expect(MockWS.instances[1].url).toContain('/api/stacks/another-stack/logs');
expect(MockWS.instances[1].url).not.toContain('.yaml');
});
// ── Container name chip ────────────────────────────────────────────
it('renders a container name chip when the WebSocket message includes a prefix', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
});
expect(container.textContent).toContain('redis');
expect(container.textContent).toContain('connected');
});
it('does not render a container name chip for old-format lines with no prefix', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: '2025-01-01T12:00:00Z plain message\n' });
});
expect(container.textContent).toContain('plain message');
expect(container.querySelector('.select-none')).toBeNull();
});
it('renders a dotted container name correctly', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'api.v1 | 2025-01-01T12:00:00Z ready\n' });
});
expect(container.textContent).toContain('api.v1');
expect(container.textContent).toContain('ready');
});
it('handles pipe in message body without false prefix extraction', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z value | other\n' });
});
expect(container.textContent).toContain('redis');
expect(container.textContent).toContain('value');
expect(container.textContent).toContain('other');
});
// ── Download ────────────────────────────────────────────────────────
it('includes container name in downloaded logs', async () => {
let capturedBlob: Blob | null = null;
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn((blob: Blob) => {
capturedBlob = blob;
return 'blob:fake';
}),
revokeObjectURL: vi.fn(),
});
render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
});
const downloadBtn = screen.getByLabelText('Download logs');
await userEvent.click(downloadBtn);
expect(capturedBlob).not.toBeNull();
const text = await capturedBlob!.text();
expect(text).toContain('[redis]');
expect(text).toContain('connected');
});
it('omits [container] prefix in download for rows without containerName', async () => {
let capturedBlob: Blob | null = null;
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn((blob: Blob) => {
capturedBlob = blob;
return 'blob:fake';
}),
revokeObjectURL: vi.fn(),
});
render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: '2025-01-01T12:00:00Z legacy line\n' });
});
const downloadBtn = screen.getByLabelText('Download logs');
await userEvent.click(downloadBtn);
expect(capturedBlob).not.toBeNull();
const text = await capturedBlob!.text();
expect(text).not.toContain('[null]');
expect(text).not.toContain('[');
expect(text).toContain('legacy line');
});
// ── Chip color mode ──────────────────────────────────────────────────
it('in unified mode (default), chip has brand classes and no inline style', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
});
const chip = container.querySelector('.select-none') as HTMLElement;
expect(chip).not.toBeNull();
expect(chip.className).toContain('text-brand/80');
expect(chip.className).toContain('bg-brand/10');
expect(chip.getAttribute('style')).toBeNull();
});
it('in per-service mode, chip has inline label-token style', async () => {
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
});
const chip = container.querySelector('.select-none') as HTMLElement;
expect(chip).not.toBeNull();
const style = chip.getAttribute('style') ?? '';
expect(style).toContain('--label-');
expect(style).toContain('-bg');
});
it('updates chip style when setting changes from unified to per-service', async () => {
const { container } = render(<StructuredLogViewer stackName="test-stack" />);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
});
const chip = container.querySelector('.select-none') as HTMLElement;
expect(chip.getAttribute('style')).toBeNull();
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
act(() => {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
});
const styleAfter = chip.getAttribute('style') ?? '';
expect(styleAfter).toContain('--label-');
expect(styleAfter).toContain('-bg');
});
});
@@ -1,48 +1,34 @@
const HUE_VARS = [
'teal', 'blue', 'purple', 'rose', 'amber',
'green', 'orange', 'pink', 'cyan', 'slate',
] as const;
import { hashLabel } from '@/lib/label-colors';
type Hue = typeof HUE_VARS[number];
function hashLabel(label: string): Hue {
let h = 0;
for (let i = 0; i < label.length; i += 1) {
h = (h * 31 + label.charCodeAt(i)) | 0;
}
return HUE_VARS[Math.abs(h) % HUE_VARS.length];
}
interface NodeLabelPillProps {
label: string;
onRemove?: () => void;
size?: 'sm' | 'md';
}
export function NodeLabelPill({ label, onRemove, size = 'md' }: NodeLabelPillProps) {
const hue = hashLabel(label);
const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0' : 'text-[11px] px-2 py-0.5';
return (
<span
className={`inline-flex items-center gap-1 rounded-md border font-mono ${sizeClasses}`}
style={{
backgroundColor: `var(--label-${hue}-bg)`,
color: `var(--label-${hue})`,
borderColor: `color-mix(in oklch, var(--label-${hue}) 30%, transparent)`,
}}
>
{label}
{onRemove && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="opacity-60 hover:opacity-100 ml-0.5 cursor-pointer"
aria-label={`Remove ${label}`}
>
×
</button>
)}
</span>
);
}
interface NodeLabelPillProps {
label: string;
onRemove?: () => void;
size?: 'sm' | 'md';
}
export function NodeLabelPill({ label, onRemove, size = 'md' }: NodeLabelPillProps) {
const hue = hashLabel(label);
const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0' : 'text-[11px] px-2 py-0.5';
return (
<span
className={`inline-flex items-center gap-1 rounded-md border font-mono ${sizeClasses}`}
style={{
backgroundColor: `var(--label-${hue}-bg)`,
color: `var(--label-${hue})`,
borderColor: `color-mix(in oklch, var(--label-${hue}) 30%, transparent)`,
}}
>
{label}
{onRemove && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="opacity-60 hover:opacity-100 ml-0.5 cursor-pointer"
aria-label={`Remove ${label}`}
>
×
</button>
)}
</span>
);
}
@@ -6,6 +6,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
import { TogglePill } from '@/components/ui/toggle-pill';
import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
import {
@@ -46,6 +47,11 @@ const HEADING_STYLE_OPTIONS: { value: HeadingStyle; label: string }[] = [
{ value: 'signature', label: 'Signature' },
];
const CHIP_COLOR_OPTIONS: { value: LogChipColorMode; label: string }[] = [
{ value: 'unified', label: 'Unified' },
{ value: 'per-service', label: 'Per service' },
];
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
@@ -129,6 +135,7 @@ function VisualCard({
export function AppearanceSection() {
const [density, setDensity] = useDensity();
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
const {
@@ -405,6 +412,18 @@ export function AppearanceSection() {
/>
</SettingsField>
)}
<SettingsField
label="Log chip color"
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
>
<SegmentedControl
value={chipColorMode}
options={CHIP_COLOR_OPTIONS}
onChange={setChipColorMode}
ariaLabel="Log chip color mode"
/>
</SettingsField>
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLogChipColorMode, LOG_CHIP_COLOR_KEY } from '../use-log-chip-color-mode';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
describe('useLogChipColorMode', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to unified when no value is stored', () => {
const { result } = renderHook(() => useLogChipColorMode());
expect(result.current[0]).toBe('unified');
});
it('returns per-service when the key is set to that value', () => {
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
const { result } = renderHook(() => useLogChipColorMode());
expect(result.current[0]).toBe('per-service');
});
it('falls back to unified on unrecognised values', () => {
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'garbage');
const { result } = renderHook(() => useLogChipColorMode());
expect(result.current[0]).toBe('unified');
});
it('setter writes to localStorage and updates state', () => {
const { result } = renderHook(() => useLogChipColorMode());
act(() => result.current[1]('per-service'));
expect(result.current[0]).toBe('per-service');
expect(localStorage.getItem(LOG_CHIP_COLOR_KEY)).toBe('per-service');
});
it('responds to SENCHO_SETTINGS_CHANGED by re-reading localStorage', () => {
const { result } = renderHook(() => useLogChipColorMode());
expect(result.current[0]).toBe('unified');
// Simulate another tab/component changing the value.
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
act(() => {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
});
expect(result.current[0]).toBe('per-service');
});
it('responds to cross-tab storage events', () => {
const { result } = renderHook(() => useLogChipColorMode());
expect(result.current[0]).toBe('unified');
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: LOG_CHIP_COLOR_KEY,
newValue: 'per-service',
}));
});
expect(result.current[0]).toBe('per-service');
});
it('ignores storage events for unrelated keys', () => {
const { result } = renderHook(() => useLogChipColorMode());
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: 'some-other-key',
newValue: 'per-service',
}));
});
expect(result.current[0]).toBe('unified');
});
});
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const LOG_CHIP_COLOR_KEY = 'sencho.log-chip-color-mode';
export type LogChipColorMode = 'unified' | 'per-service';
function readStored(): LogChipColorMode {
if (typeof window === 'undefined') return 'unified';
try {
return window.localStorage.getItem(LOG_CHIP_COLOR_KEY) === 'per-service' ? 'per-service' : 'unified';
} catch {
return 'unified';
}
}
export function useLogChipColorMode(): [LogChipColorMode, (next: LogChipColorMode) => void] {
const [mode, setModeState] = useState<LogChipColorMode>(readStored);
useEffect(() => {
function onSettingsChanged() {
setModeState(readStored());
}
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
}, []);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== LOG_CHIP_COLOR_KEY) return;
setModeState(event.newValue === 'per-service' ? 'per-service' : 'unified');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setMode = useCallback((next: LogChipColorMode) => {
try {
window.localStorage.setItem(LOG_CHIP_COLOR_KEY, next);
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
setModeState(next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}, []);
return [mode, setMode];
}
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { HUE_VARS, hashLabel } from '@/lib/label-colors';
describe('HUE_VARS', () => {
it('has exactly 10 entries', () => {
expect(HUE_VARS).toHaveLength(10);
});
});
describe('hashLabel', () => {
it('returns a valid hue from HUE_VARS', () => {
const hue = hashLabel('redis');
expect(HUE_VARS).toContain(hue);
});
it('returns the same hue for the same input', () => {
expect(hashLabel('redis')).toBe(hashLabel('redis'));
});
it('returns different hues for different inputs (typical case)', () => {
// Not a strict guarantee, but highly likely with 10 buckets.
const a = hashLabel('redis');
const b = hashLabel('postgres');
// They may collide, but the function must be stable.
expect(HUE_VARS).toContain(a);
expect(HUE_VARS).toContain(b);
});
});
+15
View File
@@ -0,0 +1,15 @@
export const HUE_VARS = [
'teal', 'blue', 'purple', 'rose', 'amber',
'green', 'orange', 'pink', 'cyan', 'slate',
] as const;
export type LabelHue = typeof HUE_VARS[number];
/** djb2-style hash that maps a string to a stable LabelHue. */
export function hashLabel(label: string): LabelHue {
let h = 0;
for (let i = 0; i < label.length; i += 1) {
h = (h * 31 + label.charCodeAt(i)) | 0;
}
return HUE_VARS[Math.abs(h) % HUE_VARS.length];
}