diff --git a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx
index 90a95a30..0317d52f 100644
--- a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx
+++ b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx
@@ -17,11 +17,15 @@ vi.mock('@/lib/monacoLoader', () => ({
},
}));
-// Stub heavy children; this test only asserts the Monaco language prop.
+// Capture StackLogsSection props for showServiceChips wiring tests.
+let lastShowServiceChips: boolean | undefined;
vi.mock('../editor-view-blocks', () => ({
StackIdentityHeader: () =>
identity-header
,
ContainersHealth: () =>
health-pane
,
- StackLogsSection: () =>
logs-pane
,
+ StackLogsSection: ({ showServiceChips }: { showServiceChips: boolean }) => {
+ lastShowServiceChips = showServiceChips;
+ return
logs-pane
;
+ },
}));
vi.mock('../../StackAnatomyPanel', () => ({
default: () =>
anatomy-pane
,
@@ -98,6 +102,7 @@ describe('EditorView Monaco language prop', () => {
lastLanguage = undefined;
lastValue = undefined;
lastReadOnly = undefined;
+ lastShowServiceChips = undefined;
});
it('passes language="ini" when the env tab is active', () => {
@@ -149,6 +154,7 @@ describe('EditorView single edit gate', () => {
lastLanguage = undefined;
lastValue = undefined;
lastReadOnly = undefined;
+ lastShowServiceChips = undefined;
});
it('shows Save & Deploy immediately without an Edit button when compose editor is open', () => {
@@ -191,3 +197,65 @@ describe('EditorView single edit gate', () => {
expect(closeComposeEditor).toHaveBeenCalledTimes(1);
});
});
+
+function containerStub(id: string, name: string): EditorViewProps['containers'][number] {
+ return {
+ Id: id,
+ Names: [name],
+ State: 'running',
+ Status: 'Up 1 minute',
+ };
+}
+
+function serviceStub(name: string) {
+ return {
+ name,
+ declaredImage: `${name}:latest`,
+ hasBuild: false,
+ expectedReplicas: 1,
+ dependsOn: [] as string[],
+ hasHealthcheck: false,
+ };
+}
+
+describe('EditorView showServiceChips wiring', () => {
+ afterEach(() => {
+ lastShowServiceChips = undefined;
+ });
+
+ it('passes false for one container and one declared service', () => {
+ render(
+
,
+ );
+ expect(lastShowServiceChips).toBe(false);
+ });
+
+ it('passes true for two containers', () => {
+ render(
+
,
+ );
+ expect(lastShowServiceChips).toBe(true);
+ });
+
+ it('passes true for one container and two declared services', () => {
+ render(
+
,
+ );
+ expect(lastShowServiceChips).toBe(true);
+ });
+});
diff --git a/frontend/src/components/EditorLayout/__tests__/StackLogsSection.test.tsx b/frontend/src/components/EditorLayout/__tests__/StackLogsSection.test.tsx
new file mode 100644
index 00000000..44b3dda4
--- /dev/null
+++ b/frontend/src/components/EditorLayout/__tests__/StackLogsSection.test.tsx
@@ -0,0 +1,77 @@
+/**
+ * StackLogsSection forwards showServiceChips to StructuredLogViewer and leaves
+ * the raw-terminal contract unchanged.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { StackLogsSection } from '../editor-view-blocks';
+
+let lastViewerShowServiceChips: boolean | undefined;
+
+vi.mock('../../StructuredLogViewer', () => ({
+ default: ({ showServiceChips }: { showServiceChips?: boolean }) => {
+ lastViewerShowServiceChips = showServiceChips;
+ return
;
+ },
+}));
+
+vi.mock('../../Terminal', () => ({
+ default: ({ stackName }: { stackName: string }) => (
+
{stackName}
+ ),
+}));
+
+vi.mock('../../ErrorBoundary', () => ({
+ default: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+describe('StackLogsSection showServiceChips forwarding', () => {
+ beforeEach(() => {
+ lastViewerShowServiceChips = undefined;
+ });
+
+ it('forwards true to StructuredLogViewer in structured mode', () => {
+ render(
+
,
+ );
+ expect(screen.getByTestId('structured-log-viewer')).toBeInTheDocument();
+ expect(lastViewerShowServiceChips).toBe(true);
+ });
+
+ it('forwards false to StructuredLogViewer in structured mode', () => {
+ render(
+
,
+ );
+ expect(screen.getByTestId('structured-log-viewer')).toBeInTheDocument();
+ expect(lastViewerShowServiceChips).toBe(false);
+ });
+
+ it('renders TerminalComponent in raw mode without requiring chip props', () => {
+ const setLogsMode = vi.fn();
+ render(
+
,
+ );
+ expect(screen.getByTestId('raw-terminal')).toHaveTextContent('web');
+ expect(screen.queryByTestId('structured-log-viewer')).toBeNull();
+ expect(lastViewerShowServiceChips).toBeUndefined();
+
+ fireEvent.click(screen.getByRole('button', { name: /Structured/i }));
+ expect(setLogsMode).toHaveBeenCalledWith('structured');
+ });
+});
diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx
index c70bb0b3..496ab00f 100644
--- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx
+++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx
@@ -346,8 +346,8 @@ export function ContainersHealth({
containersLoadError = null,
onRetryContainersLoad,
}: ContainersHealthProps) {
- // Multi-service only (§12): a single-service stack keeps the existing flat
- // layout untouched, including its per-container Start/Stop/Restart kebab.
+ // Multi-service only: a single-service stack keeps the existing flat layout
+ // untouched, including its per-container Start/Stop/Restart kebab.
const isMultiService = effectiveServices.length > 1;
const [copiedUrlId, setCopiedUrlId] = useState
(null);
const copiedUrlTimerRef = useRef(null);
@@ -445,9 +445,9 @@ export function ContainersHealth({
) : null;
// One container card. `hideServiceMenu` drops the per-container
- // Start/Stop/Restart kebab on multi-service stacks, where the declared-
- // service header above owns that action instead (§12 point 4: child cards
- // keep only logs, shell, ports, metrics).
+ // Start/Stop/Restart kebab on multi-service stacks; the declared-service
+ // header above owns lifecycle actions. Child cards keep logs, shell, ports,
+ // and metrics only.
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
@@ -807,6 +807,8 @@ export interface StackLogsSectionProps {
stackName: string;
logsMode: 'structured' | 'raw';
setLogsMode: (mode: 'structured' | 'raw') => void;
+ /** True when the stack has more than one service or container; gates log chips. */
+ showServiceChips: boolean;
/** When set, the structured viewer shows an expand control that collapses
* the Command Center to give the logs more vertical room. */
logsExpanded?: boolean;
@@ -814,7 +816,7 @@ export interface StackLogsSectionProps {
}
// Logs pane: structured / raw-terminal toggle + the live viewer.
-export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpanded, onToggleLogsExpand }: StackLogsSectionProps) {
+export function StackLogsSection({ stackName, logsMode, setLogsMode, showServiceChips, logsExpanded, onToggleLogsExpand }: StackLogsSectionProps) {
return (
@@ -844,7 +846,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpande
{logsMode === 'structured' ? (
-
+
) : (
diff --git a/frontend/src/components/StructuredLogViewer.tsx b/frontend/src/components/StructuredLogViewer.tsx
index e9086de2..150f16d1 100644
--- a/frontend/src/components/StructuredLogViewer.tsx
+++ b/frontend/src/components/StructuredLogViewer.tsx
@@ -3,11 +3,17 @@ import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Download, RefreshCw, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
-import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
+import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { hashLabel } from '@/lib/label-colors';
interface StructuredLogViewerProps {
stackName: string;
+ /**
+ * When true, render a service chip on prefixed log rows.
+ * Pass true only for multi-service or multi-container stacks; defaults to
+ * false so single-service streams stay uncluttered.
+ */
+ showServiceChips?: boolean;
/** When set, renders an expand/collapse control next to the download button. */
expanded?: boolean;
onToggleExpand?: () => void;
@@ -20,7 +26,7 @@ interface LogRow {
ts: string | null;
level: LogLevel;
message: string;
- /** Normalized service name extracted from the log prefix, or null for synthetic / old-format rows. */
+ /** Display name from the log prefix (normalized Compose service name), 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;
@@ -69,7 +75,36 @@ function formatTs(iso: string | null): string {
return `${hh}:${mm}:${ss}`;
}
-export default function StructuredLogViewer({ stackName, expanded, onToggleExpand }: StructuredLogViewerProps) {
+function stackDisplayName(stackName: string): string {
+ return stackName.replace(/\.(yml|yaml)$/, '');
+}
+
+function LogServiceChip({ name, colorMode }: { name: string; colorMode: LogChipColorMode }) {
+ const perService = colorMode === 'per-service';
+ const labelKey = hashLabel(name);
+ return (
+
+ {name}
+
+ );
+}
+
+export default function StructuredLogViewer({ stackName, showServiceChips = false, expanded, onToggleExpand }: StructuredLogViewerProps) {
const [rows, setRows] = useState
([]);
const [filter, setFilter] = useState('all');
const [following, setFollowing] = useState(true);
@@ -91,7 +126,7 @@ export default function StructuredLogViewer({ stackName, expanded, onToggleExpan
setFollowing(true);
followingRef.current = true;
- const cleanStackName = stackName.replace(/\.(yml|yaml)$/, '');
+ const cleanStackName = stackDisplayName(stackName);
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const wsUrl = `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`;
@@ -238,12 +273,12 @@ export default function StructuredLogViewer({ stackName, expanded, onToggleExpan
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = `${stackName.replace(/\.(yml|yaml)$/, '')}-logs.txt`;
+ a.download = `${stackDisplayName(stackName)}-logs.txt`;
a.click();
URL.revokeObjectURL(url);
};
- const label = `logs · ${stackName.replace(/\.(yml|yaml)$/, '')}`;
+ const label = `logs · ${stackDisplayName(stackName)}`;
return (
@@ -346,25 +381,8 @@ export default function StructuredLogViewer({ stackName, expanded, onToggleExpan
{row.level}
- {row.containerName && (
-
- {row.containerName}
-
+ {showServiceChips && row.containerName && (
+
)}
{row.message}
diff --git a/frontend/src/components/__tests__/StructuredLogViewer.test.tsx b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx
index e335903e..716c52ad 100644
--- a/frontend/src/components/__tests__/StructuredLogViewer.test.tsx
+++ b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx
@@ -1,7 +1,7 @@
/**
* 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).
+ * row clearing, auto-follow reset, level filter), service 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';
@@ -182,51 +182,78 @@ describe('StructuredLogViewer', () => {
expect(MockWS.instances[1].url).not.toContain('.yaml');
});
- // ── Container name chip ────────────────────────────────────────────
+ // ── Service chip ───────────────────────────────────────────────────
- it('renders a container name chip when the WebSocket message includes a prefix', async () => {
- const { container } = render(
);
+ it('renders a service chip when showServiceChips is true and the line has a prefix', async () => {
+ 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');
+ const chip = screen.getByTitle('redis');
+ expect(chip).toHaveTextContent('redis');
+ expect(screen.getByText('connected')).toBeInTheDocument();
});
- it('does not render a container name chip for old-format lines with no prefix', async () => {
- const { container } = render(
);
+ it('hides the chip by default but keeps the message and download attribution', 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' });
+ });
+
+ expect(screen.queryByTitle('redis')).toBeNull();
+ expect(screen.getByText('connected')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByLabelText('Download logs'));
+ expect(capturedBlob).not.toBeNull();
+ const text = await capturedBlob!.text();
+ expect(text).toContain('[redis]');
+ expect(text).toContain('connected');
+ });
+
+ it('does not render a service chip for old-format lines with no prefix', async () => {
+ 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();
+ expect(screen.getByText('plain message')).toBeInTheDocument();
+ expect(screen.queryByTitle('plain message')).toBeNull();
});
it('renders a dotted container name correctly', async () => {
- const { container } = render(
);
+ 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');
+ expect(screen.getByTitle('api.v1')).toHaveTextContent('api.v1');
+ expect(screen.getByText('ready')).toBeInTheDocument();
});
it('handles pipe in message body without false prefix extraction', async () => {
- const { container } = render(
);
+ 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');
+ expect(screen.getByTitle('redis')).toHaveTextContent('redis');
+ expect(screen.getByText(/value \| other/)).toBeInTheDocument();
});
// ── Download ────────────────────────────────────────────────────────
@@ -242,7 +269,7 @@ describe('StructuredLogViewer', () => {
revokeObjectURL: vi.fn(),
});
- render(
);
+ render(
);
await act(async () => {
MockWS.instances[0].onopen?.();
MockWS.instances[0].onmessage?.({ data: 'redis | 2025-01-01T12:00:00Z connected\n' });
@@ -287,14 +314,13 @@ describe('StructuredLogViewer', () => {
// ── Chip color mode ──────────────────────────────────────────────────
it('in unified mode (default), chip has brand classes and no inline style', async () => {
- const { container } = render(
);
+ 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 chip = screen.getByTitle('redis');
expect(chip.className).toContain('text-brand/80');
expect(chip.className).toContain('bg-brand/10');
expect(chip.getAttribute('style')).toBeNull();
@@ -302,27 +328,26 @@ describe('StructuredLogViewer', () => {
it('in per-service mode, chip has inline label-token style', async () => {
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
- const { container } = render(
);
+ 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 chip = screen.getByTitle('redis');
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(
);
+ 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;
+ const chip = screen.getByTitle('redis');
expect(chip.getAttribute('style')).toBeNull();
localStorage.setItem(LOG_CHIP_COLOR_KEY, 'per-service');
diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx
index 05099453..684671f7 100644
--- a/frontend/src/components/settings/AppearanceSection.tsx
+++ b/frontend/src/components/settings/AppearanceSection.tsx
@@ -447,7 +447,7 @@ export function AppearanceSection({
{
expect(screen.getByText('Constrained graphics')).toBeTruthy();
});
+ it('states that log chip color applies on multi-service or multi-container stacks', () => {
+ render();
+ expect(
+ screen.getByText(/Applies to service chips on multi-service or multi-container stacks/i),
+ ).toBeTruthy();
+ });
+
it('readability locks the header + chart controls and disables the glow slider', () => {
const { container } = render();
// Baseline: nothing reduced, so no slider is disabled.