mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -89,7 +89,7 @@ Sencho uses three type contexts. The interface and data faces are yours to chang
|
||||
The **Display** group holds layout and log-chip preferences for this browser:
|
||||
|
||||
- **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards).
|
||||
- **Log chip color** controls how service chips are colored in log views. **Unified** uses the accent color for all service chips. **Per service** assigns each service a stable label color for faster visual scanning when following multiple services at once.
|
||||
- **Log chip color** controls how service chips are colored in log views on multi-service or multi-container stacks. Single-service, single-container stacks do not show chips. **Unified** uses the accent color for all service chips. **Per service** assigns each service a stable label color for faster visual scanning when following multiple services at once.
|
||||
|
||||
## Navigation
|
||||
|
||||
|
||||
@@ -235,10 +235,10 @@ Single-service stacks keep the existing flat container layout; service headers d
|
||||
The logs area at the bottom of the stack view has two modes. Toggle between them with the segmented control in the top-right of the panel; your choice is remembered for next time.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-view/logs-viewer.png" alt="Logs panel in Structured mode for the plex stack with toolbar, level filter pills, and timestamped INFO log rows" />
|
||||
<img src="/images/stack-view/logs-viewer.png" alt="Logs panel in Structured mode with toolbar, level filter pills, and timestamped INFO log rows without service chips on a single-service stack" />
|
||||
</Frame>
|
||||
|
||||
**Structured** (default) parses every line into a DOM row with three columns: local time, level badge, and message. Level detection is automatic:
|
||||
**Structured** (default) parses every line into a DOM row with three columns: local time, level badge, and message. On multi-service or multi-container stacks, each row also shows a service chip so you can tell which service wrote the line; single-service, single-container stacks omit the chip. Level detection is automatic:
|
||||
|
||||
- `err` rows get a red left rail and a subtle rose tint.
|
||||
- `warn` rows get a warm tint.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 115 KiB |
@@ -139,7 +139,7 @@ A live preview card shows a sample fleet-status tile so you can see a color choi
|
||||
| Control | What it does |
|
||||
|---------|--------------|
|
||||
| **Density** | **Comfortable** (default spacing, roomier rows and tiles for review and orientation) or **Compact** (tighter rows and tiles, fits more stacks, tasks, and audit entries on screen at once). Affects the dashboard stack table, the resource gauge strip, the Settings Hub sidebar, the Schedules and Audit Log tables, and every other data table in Sencho. Typography, color, and layout structure stay the same; only vertical padding compresses. |
|
||||
| **Log chip color** | **Unified** uses the accent color for every service's log chip. **Per service** assigns each service a stable label color for faster visual scanning across a busy log stream. |
|
||||
| **Log chip color** | Applies on multi-service or multi-container stacks (chips are hidden for a single service with a single container). **Unified** uses the accent color for every service's log chip. **Per service** assigns each service a stable label color for faster visual scanning across a busy log stream. |
|
||||
|
||||
### Navigation
|
||||
|
||||
|
||||
@@ -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