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:
Anso
2026-07-23 20:22:57 -04:00
committed by GitHub
parent 85842cc547
commit a89498ae5b
14 changed files with 359 additions and 92 deletions
@@ -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)]">