mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
fix(ui): hide log service chips on single-service stacks (#1689)
Service chips only differentiate multi-service or multi-container log streams. Gate rendering with the same layout criterion already used in stack details, while keeping parsed prefixes and download attribution intact.
This commit is contained in:
@@ -181,8 +181,8 @@ export interface EditorViewProps {
|
||||
action: 'start' | 'stop' | 'restart',
|
||||
serviceName: string,
|
||||
) => Promise<void>;
|
||||
// Declared-service facts for the multi-service header split (§12). Empty
|
||||
// on single-service stacks and older remotes (capability-gated fetch), so
|
||||
// Declared-service facts for the multi-service header layout. Empty on
|
||||
// single-service stacks and older remotes (capability-gated fetch), so
|
||||
// ContainersHealth falls back to the flat single-service layout. Optional
|
||||
// so callers/tests that never deal in services can omit them.
|
||||
effectiveServices?: EffectiveServiceSpec[];
|
||||
@@ -388,9 +388,8 @@ export function EditorView(props: EditorViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Declared-service headers (§12) need the same expandable, scroll-wrapped
|
||||
// layout as a multi-container stack even when only one container of a
|
||||
// multi-service stack is currently running.
|
||||
// Multi-service stacks need the same expandable, scroll-wrapped layout as a
|
||||
// multi-container stack even when only one container is currently running.
|
||||
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
||||
|
||||
// Below md, render the segmented full-screen mobile detail instead of the
|
||||
@@ -401,6 +400,17 @@ export function EditorView(props: EditorViewProps) {
|
||||
return <MobileStackDetail {...props} />;
|
||||
}
|
||||
|
||||
const stackLogsSection = (
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
showServiceChips={isMultiContainerLayout}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={toggleLogsExpand}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={`grid gap-6 ${filesFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'} min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]`}>
|
||||
@@ -518,23 +528,9 @@ export function EditorView(props: EditorViewProps) {
|
||||
Hidden when containers are expanded to fill the column. */}
|
||||
{!containersExpanded && (isMultiContainerLayout ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={toggleLogsExpand}
|
||||
/>
|
||||
{stackLogsSection}
|
||||
</div>
|
||||
) : (
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={toggleLogsExpand}
|
||||
/>
|
||||
))}
|
||||
) : stackLogsSection)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { MobileStackDetail } from './MobileStackDetail';
|
||||
import type { EditorViewProps } from './EditorView';
|
||||
import type { ContainerInfo } from './EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
|
||||
// The detail's heavy children stream logs, parse compose, and render container
|
||||
// stats; stub them with markers so this test focuses on segment behavior and the
|
||||
// mobile editing flow.
|
||||
// mobile editing flow. Capture showServiceChips for wiring assertions.
|
||||
let lastShowServiceChips: boolean | undefined;
|
||||
vi.mock('./editor-view-blocks', () => ({
|
||||
StackIdentityHeader: () => <div>identity-header</div>,
|
||||
ContainersHealth: () => <div>health-pane</div>,
|
||||
StackLogsSection: () => <div>logs-pane</div>,
|
||||
StackLogsSection: ({ showServiceChips }: { showServiceChips: boolean }) => {
|
||||
lastShowServiceChips = showServiceChips;
|
||||
return <div>logs-pane</div>;
|
||||
},
|
||||
}));
|
||||
// Prop-aware so the edit affordance (canEdit + onEditCompose) is exercised, not
|
||||
// just the read-only marker.
|
||||
@@ -298,3 +304,65 @@ describe('MobileStackDetail mobile editing', () => {
|
||||
expect(setContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function containerStub(id: string, name: string): ContainerInfo {
|
||||
return {
|
||||
Id: id,
|
||||
Names: [name],
|
||||
State: 'running',
|
||||
Status: 'Up 1 minute',
|
||||
};
|
||||
}
|
||||
|
||||
function serviceStub(name: string): EffectiveServiceSpec {
|
||||
return {
|
||||
name,
|
||||
declaredImage: `${name}:latest`,
|
||||
hasBuild: false,
|
||||
expectedReplicas: 1,
|
||||
dependsOn: [],
|
||||
hasHealthcheck: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileStackDetail showServiceChips wiring', () => {
|
||||
afterEach(() => {
|
||||
lastShowServiceChips = undefined;
|
||||
});
|
||||
|
||||
it('passes false for one container and one declared service', () => {
|
||||
render(
|
||||
<MobileStackDetail
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web')],
|
||||
effectiveServices: [serviceStub('web')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(false);
|
||||
});
|
||||
|
||||
it('passes true for two containers', () => {
|
||||
render(
|
||||
<MobileStackDetail
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web'), containerStub('c2', '/db')],
|
||||
effectiveServices: [serviceStub('web')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(true);
|
||||
});
|
||||
|
||||
it('passes true for one container and two declared services', () => {
|
||||
render(
|
||||
<MobileStackDetail
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web')],
|
||||
effectiveServices: [serviceStub('web'), serviceStub('db')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices,
|
||||
effectiveServices = [],
|
||||
serviceUpdateStatuses,
|
||||
serviceUpdateInProgress,
|
||||
onRequestServiceUpdate,
|
||||
@@ -88,6 +88,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
const [segment, setSegment] = useState<Segment>('logs');
|
||||
|
||||
const safeContainers = containers || [];
|
||||
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canEditStack = can('stack:edit', 'stack', stackName);
|
||||
|
||||
@@ -241,7 +242,12 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
</div>
|
||||
)}
|
||||
{segment === 'logs' && (
|
||||
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
showServiceChips={isMultiContainerLayout}
|
||||
/>
|
||||
)}
|
||||
{segment === 'compose' && (
|
||||
<div className="min-h-0 flex-1">
|
||||
|
||||
@@ -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: () => <div>identity-header</div>,
|
||||
ContainersHealth: () => <div>health-pane</div>,
|
||||
StackLogsSection: () => <div>logs-pane</div>,
|
||||
StackLogsSection: ({ showServiceChips }: { showServiceChips: boolean }) => {
|
||||
lastShowServiceChips = showServiceChips;
|
||||
return <div>logs-pane</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../StackAnatomyPanel', () => ({
|
||||
default: () => <div>anatomy-pane</div>,
|
||||
@@ -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(
|
||||
<EditorView
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web')],
|
||||
effectiveServices: [serviceStub('web')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(false);
|
||||
});
|
||||
|
||||
it('passes true for two containers', () => {
|
||||
render(
|
||||
<EditorView
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web'), containerStub('c2', '/db')],
|
||||
effectiveServices: [serviceStub('web')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(true);
|
||||
});
|
||||
|
||||
it('passes true for one container and two declared services', () => {
|
||||
render(
|
||||
<EditorView
|
||||
{...makeProps({
|
||||
containers: [containerStub('c1', '/web')],
|
||||
effectiveServices: [serviceStub('web'), serviceStub('db')],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(lastShowServiceChips).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <div data-testid="structured-log-viewer" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../Terminal', () => ({
|
||||
default: ({ stackName }: { stackName: string }) => (
|
||||
<div data-testid="raw-terminal">{stackName}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../ErrorBoundary', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
describe('StackLogsSection showServiceChips forwarding', () => {
|
||||
beforeEach(() => {
|
||||
lastViewerShowServiceChips = undefined;
|
||||
});
|
||||
|
||||
it('forwards true to StructuredLogViewer in structured mode', () => {
|
||||
render(
|
||||
<StackLogsSection
|
||||
stackName="web"
|
||||
logsMode="structured"
|
||||
setLogsMode={vi.fn()}
|
||||
showServiceChips
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('structured-log-viewer')).toBeInTheDocument();
|
||||
expect(lastViewerShowServiceChips).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards false to StructuredLogViewer in structured mode', () => {
|
||||
render(
|
||||
<StackLogsSection
|
||||
stackName="web"
|
||||
logsMode="structured"
|
||||
setLogsMode={vi.fn()}
|
||||
showServiceChips={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<StackLogsSection
|
||||
stackName="web"
|
||||
logsMode="raw"
|
||||
setLogsMode={setLogsMode}
|
||||
showServiceChips={false}
|
||||
/>,
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const copiedUrlTimerRef = useRef<number | null>(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 (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -844,7 +846,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpande
|
||||
</div>
|
||||
{logsMode === 'structured' ? (
|
||||
<ErrorBoundary>
|
||||
<StructuredLogViewer stackName={stackName} expanded={logsExpanded} onToggleExpand={onToggleLogsExpand} />
|
||||
<StructuredLogViewer stackName={stackName} showServiceChips={showServiceChips} expanded={logsExpanded} onToggleExpand={onToggleLogsExpand} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10px] tracking-wide rounded px-1.5 py-px mr-1.5 select-none',
|
||||
perService ? 'border' : 'text-brand/80 bg-brand/10',
|
||||
)}
|
||||
title={name}
|
||||
style={
|
||||
perService
|
||||
? {
|
||||
backgroundColor: `var(--label-${labelKey}-bg)`,
|
||||
color: `var(--label-${labelKey})`,
|
||||
borderColor: `color-mix(in oklch, var(--label-${labelKey}) 30%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StructuredLogViewer({ stackName, showServiceChips = false, expanded, onToggleExpand }: StructuredLogViewerProps) {
|
||||
const [rows, setRows] = useState<LogRow[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>('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 (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
@@ -346,25 +381,8 @@ export default function StructuredLogViewer({ stackName, expanded, onToggleExpan
|
||||
{row.level}
|
||||
</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>
|
||||
{showServiceChips && row.containerName && (
|
||||
<LogServiceChip name={row.containerName} colorMode={chipColorMode} />
|
||||
)}
|
||||
{row.message}
|
||||
</span>
|
||||
|
||||
@@ -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(<StructuredLogViewer stackName="test-stack" />);
|
||||
it('renders a service chip when showServiceChips is true and the line has a prefix', async () => {
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
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(<StructuredLogViewer stackName="test-stack" showServiceChips={false} />);
|
||||
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(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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(<StructuredLogViewer stackName="test-stack" />);
|
||||
render(<StructuredLogViewer stackName="test-stack" showServiceChips />);
|
||||
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');
|
||||
|
||||
@@ -447,7 +447,7 @@ export function AppearanceSection({
|
||||
|
||||
<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."
|
||||
helper="Applies to service chips on multi-service or multi-container stacks. Unified uses the accent color for all chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={chipColorMode}
|
||||
|
||||
@@ -84,6 +84,13 @@ describe('AppearanceSection', () => {
|
||||
expect(screen.getByText('Constrained graphics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('states that log chip color applies on multi-service or multi-container stacks', () => {
|
||||
render(<AppearanceSection />);
|
||||
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(<AppearanceSection />);
|
||||
// Baseline: nothing reduced, so no slider is disabled.
|
||||
|
||||
Reference in New Issue
Block a user