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
@@ -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];
}