mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user