mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 21:48:45 +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)]">
|
||||
|
||||
Reference in New Issue
Block a user