mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
feat(sidebar): surface partial status for multi-container stacks (#1426)
Bulk stack-status aggregation collapsed a stack to "running" as soon as any container was up, so a multi-container stack with crashed containers showed a green UP pill and the degradation was invisible from the sidebar. Add a crash-aware "partial" state: a stack is partial when at least one container is running and at least one has genuinely failed (exited with a non-zero code, dead, or crash-looping). Cleanly finished one-shot containers (exit 0) and clean restart-policy cycling do not count, so an app with a completed init job stays UP. The exit code is read from the container Status string, so no extra inspect calls are needed. Render partial as an amber PT pill with a hover tooltip showing the running/total count, fold it into the Down filter (needs-attention), and treat it as running for context-menu lifecycle actions so operators keep stop/restart/update. The dashboard stack-health table, cross-node search rows, and the command palette all pick up the new state through the shared status surfaces.
This commit is contained in:
@@ -32,6 +32,7 @@ export interface StackListProps {
|
||||
searchQuery: string;
|
||||
stackLabelMap: Record<string, Label[]>;
|
||||
stackStatuses: Record<string, StackRowStatus | undefined>;
|
||||
stackCounts: Record<string, { running: number; total: number } | undefined>;
|
||||
stackUpdates: Record<string, boolean>;
|
||||
gitSourcePendingMap: Record<string, boolean>;
|
||||
pinnedFiles: string[];
|
||||
@@ -117,7 +118,7 @@ interface StackListBulkProps {
|
||||
|
||||
export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
const {
|
||||
files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses,
|
||||
files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses, stackCounts,
|
||||
stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
|
||||
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
|
||||
bulkMode, selectedFiles, onToggleSelect,
|
||||
@@ -176,6 +177,8 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
file={file}
|
||||
displayName={getDisplayName(file)}
|
||||
status={stackStatuses[file] ?? 'unknown'}
|
||||
running={stackCounts[file]?.running}
|
||||
total={stackCounts[file]?.total}
|
||||
isBusy={isBusy(file)}
|
||||
isActive={selectedFile === file}
|
||||
labels={stackLabelMap[file] ?? []}
|
||||
|
||||
@@ -13,6 +13,9 @@ interface StackRowProps {
|
||||
file: string;
|
||||
displayName: string;
|
||||
status: StackRowStatus;
|
||||
// Running/total container counts (set for any stack with containers); consumed only for the partial-stack pill tooltip.
|
||||
running?: number;
|
||||
total?: number;
|
||||
isBusy: boolean;
|
||||
isActive: boolean;
|
||||
labels: Label[];
|
||||
@@ -43,7 +46,7 @@ const MAX_VISIBLE_LABELS = 3;
|
||||
|
||||
export function StackRow(props: StackRowProps) {
|
||||
const {
|
||||
file, displayName, status, isBusy, isActive, labels,
|
||||
file, displayName, status, running, total, isBusy, isActive, labels,
|
||||
hasUpdate, hasGitPending, onSelect, kebabSlot,
|
||||
bulkMode = false, isSelected = false, onToggleSelect,
|
||||
} = props;
|
||||
@@ -88,9 +91,15 @@ export function StackRow(props: StackRowProps) {
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Status pill */}
|
||||
{/* Status pill. Partial stacks add a hover tooltip with the running/total count. */}
|
||||
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}>
|
||||
{isBusy ? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} /> : statusText(status)}
|
||||
{isBusy ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
|
||||
) : status === 'partial' && running !== undefined && total !== undefined ? (
|
||||
<RowTooltip trigger={<span>{statusText(status)}</span>} label={`${running}/${total} running`} />
|
||||
) : (
|
||||
statusText(status)
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Stack name */}
|
||||
|
||||
@@ -36,6 +36,26 @@ describe('StackRow', () => {
|
||||
expect(screen.getByText('--')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PT with the amber class for partial', () => {
|
||||
const { container } = render(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
|
||||
expect(screen.getByText('PT')).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-warning')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wraps the partial pill in a hover tooltip', () => {
|
||||
// jsdom does not mount the cursor-follow label, so assert the PT trigger is
|
||||
// wrapped in the RowTooltip cursor-container; the visible "3/5 running"
|
||||
// tooltip text is verified in the Playwright drive.
|
||||
const { container } = render(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
|
||||
expect(screen.getByText('PT').closest('[data-slot="cursor-container"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-slot="cursor-container"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not wrap a non-partial pill in a tooltip', () => {
|
||||
render(<StackRow {...base({ status: 'running' })} />);
|
||||
expect(screen.getByText('UP').closest('[data-slot="cursor-container"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders cyan rail only when active', () => {
|
||||
const { rerender } = render(<StackRow {...base({ isActive: false })} />);
|
||||
expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { statusText, statusColor, isDownStatus } from '../stack-status-utils';
|
||||
|
||||
describe('stack-status-utils', () => {
|
||||
describe('statusText', () => {
|
||||
it('maps each status to its pill label', () => {
|
||||
expect(statusText('running')).toBe('UP');
|
||||
expect(statusText('exited')).toBe('DN');
|
||||
expect(statusText('partial')).toBe('PT');
|
||||
expect(statusText('unknown')).toBe('--');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusColor', () => {
|
||||
it('maps partial to the amber warning token', () => {
|
||||
expect(statusColor('partial', false)).toBe('text-warning');
|
||||
});
|
||||
it('uses the muted spinner color while busy regardless of status', () => {
|
||||
expect(statusColor('partial', true)).toBe('text-muted-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDownStatus', () => {
|
||||
it('treats exited and partial as needing attention', () => {
|
||||
expect(isDownStatus('exited')).toBe(true);
|
||||
expect(isDownStatus('partial')).toBe(true);
|
||||
});
|
||||
it('does not treat running, unknown, or a missing status as down', () => {
|
||||
expect(isDownStatus('running')).toBe(false);
|
||||
expect(isDownStatus('unknown')).toBe(false);
|
||||
expect(isDownStatus(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
export type StackRowStatus = 'running' | 'exited' | 'unknown';
|
||||
export type StackRowStatus = 'running' | 'exited' | 'unknown' | 'partial';
|
||||
|
||||
export function statusText(status: StackRowStatus): string {
|
||||
if (status === 'running') return 'UP';
|
||||
if (status === 'exited') return 'DN';
|
||||
if (status === 'partial') return 'PT';
|
||||
return '--';
|
||||
}
|
||||
|
||||
@@ -10,5 +11,11 @@ export function statusColor(status: StackRowStatus, isBusy: boolean): string {
|
||||
if (isBusy) return 'text-muted-foreground';
|
||||
if (status === 'running') return 'text-success';
|
||||
if (status === 'exited') return 'text-destructive';
|
||||
if (status === 'partial') return 'text-warning';
|
||||
return 'text-stat-icon';
|
||||
}
|
||||
|
||||
/** Stacks the Down filter surfaces: fully stopped, or partially crashed. */
|
||||
export function isDownStatus(status: StackRowStatus | undefined): boolean {
|
||||
return status === 'exited' || status === 'partial';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user