diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index aa2443f6..6f641371 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -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).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).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).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).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(), + ); + }); +}); diff --git a/backend/src/__tests__/log-formatter.test.ts b/backend/src/__tests__/log-formatter.test.ts new file mode 100644 index 00000000..9d619445 --- /dev/null +++ b/backend/src/__tests__/log-formatter.test.ts @@ -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); + }); +}); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 54a139f7..15b1388b 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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' diff --git a/backend/src/services/LogFormatter.ts b/backend/src/services/LogFormatter.ts index dd78464a..76d3f953 100644 --- a/backend/src/services/LogFormatter.ts +++ b/backend/src/services/LogFormatter.ts @@ -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 }) diff --git a/frontend/src/components/StructuredLogViewer.tsx b/frontend/src/components/StructuredLogViewer.tsx index d1db7df1..233d92d1 100644 --- a/frontend/src/components/StructuredLogViewer.tsx +++ b/frontend/src/components/StructuredLogViewer.tsx @@ -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 { - 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('all'); const [following, setFollowing] = useState(true); const [connectionState, setConnectionState] = useState<'connecting' | 'open' | 'reconnecting'>('connecting'); + const [chipColorMode] = useLogChipColorMode(); const scrollRef = useRef(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} - {row.message} + + {row.containerName && ( + + {row.containerName} + + )} + {row.message} + )) )} diff --git a/frontend/src/components/__tests__/StructuredLogViewer.test.tsx b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx index 478c5369..e335903e 100644 --- a/frontend/src/components/__tests__/StructuredLogViewer.test.tsx +++ b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); }); diff --git a/frontend/src/components/blueprints/NodeLabelPill.tsx b/frontend/src/components/blueprints/NodeLabelPill.tsx index 87bd125f..18fc3e62 100644 --- a/frontend/src/components/blueprints/NodeLabelPill.tsx +++ b/frontend/src/components/blueprints/NodeLabelPill.tsx @@ -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 ( - - {label} - {onRemove && ( - - )} - - ); -} + 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 ( + + {label} + {onRemove && ( + + )} + + ); + } diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index 3bdc79c8..fdbf3b45 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -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() { /> )} + + + +

diff --git a/frontend/src/hooks/__tests__/use-log-chip-color-mode.test.ts b/frontend/src/hooks/__tests__/use-log-chip-color-mode.test.ts new file mode 100644 index 00000000..65843c81 --- /dev/null +++ b/frontend/src/hooks/__tests__/use-log-chip-color-mode.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/hooks/use-log-chip-color-mode.ts b/frontend/src/hooks/use-log-chip-color-mode.ts new file mode 100644 index 00000000..1285cd63 --- /dev/null +++ b/frontend/src/hooks/use-log-chip-color-mode.ts @@ -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(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]; +} diff --git a/frontend/src/lib/__tests__/label-colors.test.ts b/frontend/src/lib/__tests__/label-colors.test.ts new file mode 100644 index 00000000..ec13ca6a --- /dev/null +++ b/frontend/src/lib/__tests__/label-colors.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/label-colors.ts b/frontend/src/lib/label-colors.ts new file mode 100644 index 00000000..67928098 --- /dev/null +++ b/frontend/src/lib/label-colors.ts @@ -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]; +}