mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -44,7 +44,7 @@ vi.mock('util', () => ({
|
||||
promisify: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import DockerController, { selectMainWebPort } from '../services/DockerController';
|
||||
import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1365,3 +1365,152 @@ describe('selectMainWebPort', () => {
|
||||
expect(selectMainWebPort([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExitCode', () => {
|
||||
it('extracts the code from an exited Status string', () => {
|
||||
expect(parseExitCode('Exited (0) 5 minutes ago')).toBe(0);
|
||||
expect(parseExitCode('Exited (137) 2 minutes ago')).toBe(137);
|
||||
});
|
||||
|
||||
it('reads the code from a restarting Status string', () => {
|
||||
expect(parseExitCode('Restarting (1) 3 seconds ago')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when no parenthesized code is present', () => {
|
||||
expect(parseExitCode('Up 3 hours')).toBeNull();
|
||||
expect(parseExitCode('Up 2 hours (healthy)')).toBeNull();
|
||||
expect(parseExitCode('Created')).toBeNull();
|
||||
expect(parseExitCode(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainerFailed', () => {
|
||||
it('treats a dead container as failed', () => {
|
||||
expect(isContainerFailed('dead', 'Dead')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a crash-looping restart as failed but a clean restart as not', () => {
|
||||
expect(isContainerFailed('restarting', 'Restarting (1) 5 seconds ago')).toBe(true);
|
||||
expect(isContainerFailed('restarting', 'Restarting (0) 2 seconds ago')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a non-zero exit as failed and a clean exit as not failed', () => {
|
||||
expect(isContainerFailed('exited', 'Exited (137) 2 minutes ago')).toBe(true);
|
||||
expect(isContainerFailed('exited', 'Exited (0) 5 minutes ago')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an exited container with an unreadable code as failed', () => {
|
||||
expect(isContainerFailed('exited', 'Exited')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat running, created, or paused containers as failed', () => {
|
||||
expect(isContainerFailed('running', 'Up 2 hours')).toBe(false);
|
||||
expect(isContainerFailed('created', 'Created')).toBe(false);
|
||||
expect(isContainerFailed('paused', 'Up 2 hours (Paused)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - getBulkStackStatuses partial status', () => {
|
||||
beforeEach(() => {
|
||||
CacheService.getInstance().flush();
|
||||
});
|
||||
|
||||
const container = (id: string, project: string, state: string, status: string) => ({
|
||||
Id: id, Names: [`/${id}`], State: state, Status: status,
|
||||
Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project },
|
||||
});
|
||||
|
||||
// Any running container triggers an inspect for uptime; stub a valid StartedAt.
|
||||
const stubInspect = () => {
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }),
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps a stack UP when a one-shot container exits cleanly', async () => {
|
||||
stubInspect();
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('clean-run', 'clean-stack', 'running', 'Up 2 hours'),
|
||||
container('clean-init', 'clean-stack', 'exited', 'Exited (0) 5 minutes ago'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['clean-stack']);
|
||||
expect(result['clean-stack'].status).toBe('running');
|
||||
expect(result['clean-stack'].running).toBe(1);
|
||||
expect(result['clean-stack'].total).toBe(2);
|
||||
});
|
||||
|
||||
it('marks a stack partial when a container crashes alongside a running one', async () => {
|
||||
stubInspect();
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('crash-run', 'crash-stack', 'running', 'Up 2 hours'),
|
||||
container('crash-exit', 'crash-stack', 'exited', 'Exited (137) 1 minute ago'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['crash-stack']);
|
||||
expect(result['crash-stack'].status).toBe('partial');
|
||||
expect(result['crash-stack'].running).toBe(1);
|
||||
expect(result['crash-stack'].total).toBe(2);
|
||||
});
|
||||
|
||||
it('marks a stack partial for a dead or restart-looping container', async () => {
|
||||
stubInspect();
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('d-run', 'dead-stack', 'running', 'Up 2 hours'),
|
||||
container('d-dead', 'dead-stack', 'dead', 'Dead'),
|
||||
container('r-run', 'restart-stack', 'running', 'Up 2 hours'),
|
||||
container('r-loop', 'restart-stack', 'restarting', 'Restarting (1) 5 seconds ago'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['dead-stack', 'restart-stack']);
|
||||
expect(result['dead-stack'].status).toBe('partial');
|
||||
expect(result['restart-stack'].status).toBe('partial');
|
||||
});
|
||||
|
||||
it('keeps a stack running when a sibling is paused rather than crashed', async () => {
|
||||
stubInspect();
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('p-run', 'paused-stack', 'running', 'Up 2 hours'),
|
||||
container('p-paused', 'paused-stack', 'paused', 'Up 2 hours (Paused)'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['paused-stack']);
|
||||
expect(result['paused-stack'].status).toBe('running');
|
||||
expect(result['paused-stack'].running).toBe(1);
|
||||
expect(result['paused-stack'].total).toBe(2);
|
||||
});
|
||||
|
||||
it('reports running when every container is up', async () => {
|
||||
stubInspect();
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('all-1', 'all-stack', 'running', 'Up 2 hours'),
|
||||
container('all-2', 'all-stack', 'running', 'Up 1 hour'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['all-stack']);
|
||||
expect(result['all-stack'].status).toBe('running');
|
||||
expect(result['all-stack'].running).toBe(2);
|
||||
expect(result['all-stack'].total).toBe(2);
|
||||
});
|
||||
|
||||
it('reports exited when no container is running, even if some crashed', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
container('down-1', 'down-stack', 'exited', 'Exited (1) 3 minutes ago'),
|
||||
container('down-2', 'down-stack', 'exited', 'Exited (0) 3 minutes ago'),
|
||||
]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['down-stack']);
|
||||
expect(result['down-stack'].status).toBe('exited');
|
||||
expect(result['down-stack'].running).toBe(0);
|
||||
expect(result['down-stack'].total).toBe(2);
|
||||
});
|
||||
|
||||
it('reports unknown for a stack with no containers', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
|
||||
const result = await DockerController.getInstance(1).getBulkStackStatuses(['empty-stack']);
|
||||
expect(result['empty-stack'].status).toBe('unknown');
|
||||
expect(result['empty-stack'].running).toBeUndefined();
|
||||
expect(result['empty-stack'].total).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FileSystemService } from '../services/FileSystemService';
|
||||
import { StackFileRootsService, STACK_SOURCE_ROOT_ID, stackSourceFileRoot, type StackFileRoot } from '../services/StackFileRootsService';
|
||||
import { FileRootGateway } from '../services/FileRootGateway';
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import DockerController, { type BulkStackInfo } from '../services/DockerController';
|
||||
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
@@ -240,7 +240,7 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => {
|
||||
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
|
||||
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number; runningSince?: number }> = {};
|
||||
const data: Record<string, BulkStackInfo> = {};
|
||||
for (const stack of stacks) {
|
||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
|
||||
|
||||
@@ -54,11 +54,45 @@ export function selectMainWebPort(
|
||||
return chosen?.PublicPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the exit code out of a Docker container Status string. listContainers
|
||||
* exposes the code only inside Status (e.g. "Exited (137) 2 minutes ago"); the
|
||||
* structured code would otherwise need a per-container inspect. Returns null when
|
||||
* no parenthesized code is present (e.g. "Up 3 hours", "Created").
|
||||
*/
|
||||
export function parseExitCode(status: string | undefined): number | null {
|
||||
if (!status) return null;
|
||||
const match = /\((\d+)\)/.exec(status);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a container represents a genuine failure (a crash) rather than a clean
|
||||
* completion. A dead container always counts. An exited or restarting container
|
||||
* counts only when it left with a non-zero exit code (read from its Status
|
||||
* string), so a finished init job (exit 0) or a container cleanly cycling under a
|
||||
* restart policy does not mark its stack as degraded, while a crash loop (e.g.
|
||||
* "Restarting (1)") does. An exited or restarting container with an unreadable
|
||||
* code is treated as failed, erring toward surfacing a crash.
|
||||
*/
|
||||
export function isContainerFailed(state: string, status: string | undefined): boolean {
|
||||
if (state === 'dead') return true;
|
||||
if (state === 'exited' || state === 'restarting') {
|
||||
const code = parseExitCode(status);
|
||||
return code === null ? true : code !== 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface BulkStackInfo {
|
||||
status: 'running' | 'exited' | 'unknown';
|
||||
status: 'running' | 'exited' | 'unknown' | 'partial';
|
||||
mainPort?: number;
|
||||
/** Unix seconds of the oldest running container's last start (approximates stack uptime). */
|
||||
runningSince?: number;
|
||||
/** Running container count for the stack (set when the stack has containers). */
|
||||
running?: number;
|
||||
/** Total container count for the stack; paired with `running` for the sidebar tooltip. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface ClassifiedImage {
|
||||
@@ -1144,6 +1178,14 @@ class DockerController {
|
||||
// only used if an inspect fails, since it never moves on restart.
|
||||
const runningByStack: Record<string, { ids: string[]; oldestCreated?: number }> = {};
|
||||
|
||||
// Per stack, tally running, genuinely-failed (crashed), and total containers
|
||||
// so the status can distinguish a fully-up stack from one that is partially
|
||||
// degraded (some running, some crashed).
|
||||
const countsByStack: Record<string, { running: number; failed: number; total: number }> = {};
|
||||
for (const name of stackNames) {
|
||||
countsByStack[name] = { running: 0, failed: 0, total: 0 };
|
||||
}
|
||||
|
||||
for (const container of allContainers as any[]) {
|
||||
const stackDir = DockerController.resolveContainerStack(
|
||||
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
|
||||
@@ -1151,8 +1193,11 @@ class DockerController {
|
||||
|
||||
if (!stackDir || !result[stackDir]) continue;
|
||||
|
||||
const counts = countsByStack[stackDir];
|
||||
counts.total += 1;
|
||||
|
||||
if (container.State === 'running') {
|
||||
result[stackDir].status = 'running';
|
||||
counts.running += 1;
|
||||
|
||||
const acc = (runningByStack[stackDir] ??= { ids: [] });
|
||||
if (typeof container.Id === 'string') acc.ids.push(container.Id);
|
||||
@@ -1168,11 +1213,25 @@ class DockerController {
|
||||
);
|
||||
if (mainPort) result[stackDir].mainPort = mainPort;
|
||||
}
|
||||
} else if (result[stackDir].status !== 'running') {
|
||||
result[stackDir].status = 'exited';
|
||||
} else if (isContainerFailed(container.State, container.Status)) {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each stack from its tallies. "partial" requires at least one
|
||||
// running and at least one crashed container, so a stack with a cleanly
|
||||
// finished one-shot container (exit 0) stays "running". A stack with no
|
||||
// containers keeps its seeded "unknown".
|
||||
for (const name of stackNames) {
|
||||
const { running, failed, total } = countsByStack[name];
|
||||
if (total === 0) continue;
|
||||
if (running === 0) result[name].status = 'exited';
|
||||
else if (failed > 0) result[name].status = 'partial';
|
||||
else result[name].status = 'running';
|
||||
result[name].running = running;
|
||||
result[name].total = total;
|
||||
}
|
||||
|
||||
// Resolve real uptime: oldest StartedAt across each stack's running
|
||||
// containers, falling back to the oldest Created when inspect is unavailable.
|
||||
const allRunningIds = Object.values(runningByStack).flatMap(s => s.ids);
|
||||
|
||||
@@ -26,8 +26,8 @@ From top to bottom:
|
||||
Four chips sit below the search box. Each shows a live count to the right of its label, and counts above 99 render as **99+** to keep the row from overflowing.
|
||||
|
||||
- **All**: every stack on the node.
|
||||
- **Up**: stacks where at least one container is running.
|
||||
- **Down**: stacks where no container is running.
|
||||
- **Up**: stacks that are running with nothing crashed. A stack with a cleanly finished one-shot container (an init job that exited without error) still counts as up.
|
||||
- **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below).
|
||||
- **Updates**: stacks with at least one image update available. The chip turns orange when the count is non-zero so you can spot pending updates at a glance.
|
||||
|
||||
<Frame>
|
||||
@@ -52,7 +52,7 @@ Right-click a stack and choose **Pin to top**. Pinned stacks sit in a dedicated
|
||||
|
||||
Each row gives you everything you need to read the stack at a glance, in a fixed column order:
|
||||
|
||||
- **Status pill** on the left. Two uppercase letters in mono type, or a spinner while a lifecycle action is in flight. `UP` means at least one container is running; `DN` means the stack is stopped.
|
||||
- **Status pill** on the left. Two uppercase letters in mono type, or a spinner while a lifecycle action is in flight. `UP` (green) means the stack is running with nothing crashed, `DN` (red) means the stack is stopped, and `PT` (amber) means the stack is partially running: at least one container is up and at least one has crashed (exited with an error, died, or is restart-looping). Hover the `PT` pill to see how many containers are running, such as `3/5 running`. A stack whose only stopped container finished cleanly (an init job that exited without error) stays `UP`.
|
||||
- **Stack name** in mono type, truncated with an ellipsis when the row gets tight.
|
||||
- **Label dots** to the right of the name. Up to three colored dots representing the stack's labels render here. If a stack carries more than three labels, a **+N** counter appears for the extras.
|
||||
- **Update indicator**. When a stack has an image update pending, an extra colored dot appears alongside the label dots. If only a Git source update is pending (no image update), a small Git branch icon shows instead. The image-update dot takes priority when both apply.
|
||||
|
||||
@@ -111,6 +111,7 @@ export default function EditorLayout() {
|
||||
isScanning,
|
||||
searchQuery, setSearchQuery,
|
||||
stackStatuses,
|
||||
stackCounts,
|
||||
stackLabelMap,
|
||||
filterChip, setFilterChip,
|
||||
bulkMode,
|
||||
@@ -629,6 +630,7 @@ export default function EditorLayout() {
|
||||
searchQuery,
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackCounts,
|
||||
stackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
|
||||
@@ -6,9 +6,13 @@ import type { Node } from '@/context/NodeContext';
|
||||
// buildMenuCtx derives canOpenApp from the active node plus the stack's
|
||||
// published port; only the fields it reads need to be real, the handler
|
||||
// closures are never invoked here.
|
||||
function makeOptions(activeNode: Node | null, stackPorts: Record<string, number | undefined>) {
|
||||
function makeOptions(
|
||||
activeNode: Node | null,
|
||||
stackPorts: Record<string, number | undefined>,
|
||||
stackStatuses: Record<string, string> = { 'web.yml': 'running' },
|
||||
) {
|
||||
const stackListState = {
|
||||
stackStatuses: { 'web.yml': 'running' },
|
||||
stackStatuses,
|
||||
stackPorts,
|
||||
isStackBusy: () => false,
|
||||
isPinned: () => false,
|
||||
@@ -62,3 +66,21 @@ describe('useSidebarContextMenu canOpenApp', () => {
|
||||
expect(result.current('web.yml').canOpenApp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSidebarContextMenu stackStatus', () => {
|
||||
it('maps a partial stack to running so it gets running-stack actions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, { 'web.yml': 'partial' })));
|
||||
expect(result.current('web.yml').stackStatus).toBe('running');
|
||||
});
|
||||
|
||||
it('passes exited and unknown through unchanged', () => {
|
||||
const exited = renderHook(() =>
|
||||
useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, { 'web.yml': 'exited' })));
|
||||
expect(exited.result.current('web.yml').stackStatus).toBe('exited');
|
||||
|
||||
const missing = renderHook(() =>
|
||||
useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, {})));
|
||||
expect(missing.result.current('web.yml').stackStatus).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +36,12 @@ export function useSidebarContextMenu({
|
||||
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
|
||||
const sName = file.replace(/\.(yml|yaml)$/, '');
|
||||
const mainPort = stackListState.stackPorts[file];
|
||||
// A partial stack has running containers, so it gets the running-stack
|
||||
// lifecycle affordances; the menu's status union stays three-state.
|
||||
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
|
||||
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
|
||||
return {
|
||||
stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown',
|
||||
stackStatus,
|
||||
// Only offer "Open App" when a browser-reachable URL can actually be built
|
||||
// (a remote node with no API host, e.g. a pilot agent, yields none).
|
||||
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
|
||||
|
||||
@@ -862,6 +862,22 @@ describe('useStackActions recovery records', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions.getStackMenuVisibility', () => {
|
||||
it('gives a partial stack the running-stack lifecycle actions', () => {
|
||||
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'partial' } as never } });
|
||||
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
|
||||
showDeploy: false, showStop: true, showRestart: true, showUpdate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows deploy (not stop/restart/update) for an exited stack', () => {
|
||||
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'exited' } as never } });
|
||||
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
|
||||
showDeploy: true, showStop: false, showRestart: false, showUpdate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions.openStackApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
|
||||
@@ -261,7 +261,10 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.envContent !== editorState.originalEnvContent;
|
||||
|
||||
const getStackMenuVisibility = (file: string) => {
|
||||
const status = stackListState.stackStatuses[file];
|
||||
// A partial stack has running containers, so it shows the running-stack
|
||||
// lifecycle actions (stop/restart/update) rather than deploy.
|
||||
const raw = stackListState.stackStatuses[file];
|
||||
const status = raw === 'partial' ? 'running' : raw;
|
||||
return {
|
||||
showDeploy: status !== 'running',
|
||||
showStop: status === 'running',
|
||||
|
||||
@@ -12,15 +12,22 @@ import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import type { StackAction, StackActionResult, ContainerInfo } from '../EditorView';
|
||||
import type { Label as StackLabel } from '../../label-types';
|
||||
import type { FilterChip } from '../../sidebar/sidebar-types';
|
||||
import { isDownStatus } from '../../sidebar/stack-status-utils';
|
||||
import type { StackRowStatus } from '../../sidebar/stack-status-utils';
|
||||
|
||||
interface StackStatus {
|
||||
[key: string]: 'running' | 'exited' | 'unknown';
|
||||
[key: string]: StackRowStatus;
|
||||
}
|
||||
|
||||
interface StackCounts {
|
||||
[key: string]: { running: number; total: number } | undefined;
|
||||
}
|
||||
|
||||
interface StackStatusInfo {
|
||||
status: 'running' | 'exited' | 'unknown';
|
||||
status: StackRowStatus;
|
||||
mainPort?: number;
|
||||
running?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface RemoteResult {
|
||||
@@ -58,6 +65,7 @@ export function useStackListState() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
||||
const [stackCounts, setStackCounts] = useState<StackCounts>({});
|
||||
const [labels, setLabels] = useState<StackLabel[]>([]);
|
||||
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
||||
const [filterChip, setFilterChip] = useState<FilterChip>('all');
|
||||
@@ -165,19 +173,23 @@ export function useStackListState() {
|
||||
// Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes)
|
||||
const statusRes = await apiFetch('/stacks/statuses');
|
||||
if (stale()) return fileList;
|
||||
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
|
||||
let bulkStatuses: Record<string, StackRowStatus> | null = null;
|
||||
const bulkPorts: Record<string, number | undefined> = {};
|
||||
const bulkCounts: StackCounts = {};
|
||||
if (statusRes.ok) {
|
||||
const raw = await statusRes.json();
|
||||
bulkStatuses = {};
|
||||
// Handle both old format (plain string) and new format ({ status, mainPort })
|
||||
// Handle both old format (plain string) and new format ({ status, mainPort, running, total })
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (typeof val === 'string') {
|
||||
bulkStatuses[key] = val as 'running' | 'exited' | 'unknown';
|
||||
bulkStatuses[key] = val as StackRowStatus;
|
||||
} else if (val && typeof val === 'object' && 'status' in val) {
|
||||
const info = val as StackStatusInfo;
|
||||
bulkStatuses[key] = info.status;
|
||||
if (info.mainPort) bulkPorts[key] = info.mainPort;
|
||||
if (info.running !== undefined && info.total !== undefined) {
|
||||
bulkCounts[key] = { running: info.running, total: info.total };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -211,6 +223,7 @@ export function useStackListState() {
|
||||
if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev;
|
||||
return bulkPorts;
|
||||
});
|
||||
setStackCounts(bulkCounts);
|
||||
refreshLabels();
|
||||
return fileList;
|
||||
} catch (error) {
|
||||
@@ -275,14 +288,14 @@ export function useStackListState() {
|
||||
const filterCounts = useMemo(() => ({
|
||||
all: filteredFiles.length,
|
||||
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
||||
down: filteredFiles.filter(f => stackStatuses[f] === 'exited').length,
|
||||
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
||||
updates: filteredFiles.filter(f => !!stackUpdates[f]).length,
|
||||
}), [filteredFiles, stackStatuses, stackUpdates]);
|
||||
|
||||
const chipFilteredFiles = useMemo(() => {
|
||||
if (filterChip === 'all') return filteredFiles;
|
||||
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
||||
if (filterChip === 'down') return filteredFiles.filter(f => stackStatuses[f] === 'exited');
|
||||
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => !!stackUpdates[f]);
|
||||
return filteredFiles;
|
||||
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
|
||||
@@ -343,7 +356,7 @@ export function useStackListState() {
|
||||
}, [bulkMode, toggleBulkMode]);
|
||||
|
||||
const remoteStackResults = useMemo(() => {
|
||||
const out: Record<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
|
||||
const out: Record<number, Array<{ file: string; status: StackRowStatus }>> = {};
|
||||
for (const hit of remoteSearchHits) {
|
||||
(out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status });
|
||||
}
|
||||
@@ -371,6 +384,7 @@ export function useStackListState() {
|
||||
searchQuery, setSearchQuery,
|
||||
stackStatuses, setStackStatuses,
|
||||
stackPorts, setStackPorts,
|
||||
stackCounts,
|
||||
labels,
|
||||
stackLabelMap,
|
||||
filterChip, setFilterChip,
|
||||
|
||||
@@ -33,6 +33,7 @@ const statusDot: Record<StackStatus, string> = {
|
||||
running: 'bg-success',
|
||||
exited: 'bg-muted-foreground',
|
||||
unknown: 'bg-muted-foreground/60',
|
||||
partial: 'bg-warning',
|
||||
};
|
||||
|
||||
interface PaletteState {
|
||||
|
||||
@@ -149,6 +149,21 @@ describe('GlobalCommandPalette', () => {
|
||||
expect(screen.queryByText('No results.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a partial stack hit with the amber status dot', () => {
|
||||
hookReturn = {
|
||||
hits: [{ nodeId: 2, nodeName: 'opsix', file: 'web.yml', status: 'partial' }],
|
||||
failedNodes: [],
|
||||
loading: false,
|
||||
};
|
||||
renderPalette();
|
||||
open();
|
||||
type('web');
|
||||
// The dialog renders in a portal outside the render container, so scope the
|
||||
// dot lookup to the hit row reached via screen.
|
||||
const row = screen.getByText('web.yml').closest('[cmdk-item]');
|
||||
expect(row?.querySelector('.bg-warning')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders stack hits and caps the list with an overflow line', () => {
|
||||
const hits: StackHit[] = Array.from({ length: 55 }, (_, i) => ({
|
||||
nodeId: 2,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline';
|
||||
import { ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
|
||||
interface StackHealthTableProps {
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
@@ -14,8 +15,6 @@ interface StackHealthTableProps {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
const WARN = 80;
|
||||
const CRIT = 90;
|
||||
// Shared by the header and data rows so their columns stay aligned. The
|
||||
// `max-md:min-w` keeps both at the same width below md, where the card scrolls
|
||||
// horizontally; desktop is unaffected by the `max-md:` prefix.
|
||||
@@ -37,15 +36,6 @@ function formatUptime(seconds: number): string {
|
||||
return `${Math.max(1, Math.floor(seconds))}s`;
|
||||
}
|
||||
|
||||
type RowState = 'healthy' | 'warn' | 'error';
|
||||
|
||||
function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState {
|
||||
if (status === 'exited') return 'error';
|
||||
if (peakCpu >= CRIT) return 'error';
|
||||
if (peakCpu >= WARN) return 'warn';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
const stateDot: Record<RowState, string> = {
|
||||
healthy: 'bg-success',
|
||||
warn: 'bg-warning',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyRow } from '../classifyRow';
|
||||
|
||||
describe('classifyRow', () => {
|
||||
it('marks a partially-crashed stack as warn (degraded), not healthy', () => {
|
||||
expect(classifyRow('partial', 0)).toBe('warn');
|
||||
});
|
||||
|
||||
it('marks an exited stack as error', () => {
|
||||
expect(classifyRow('exited', 0)).toBe('error');
|
||||
});
|
||||
|
||||
it('marks a running stack with low CPU as healthy', () => {
|
||||
expect(classifyRow('running', 0)).toBe('healthy');
|
||||
});
|
||||
|
||||
it('escalates a partial stack with critical CPU to error', () => {
|
||||
expect(classifyRow('partial', 95)).toBe('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { StackStatusEntry } from './types';
|
||||
|
||||
export type RowState = 'healthy' | 'warn' | 'error';
|
||||
|
||||
const WARN = 80;
|
||||
const CRIT = 90;
|
||||
|
||||
export function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState {
|
||||
if (status === 'exited') return 'error';
|
||||
if (peakCpu >= CRIT) return 'error';
|
||||
// A partially-crashed stack is degraded, not down: surface it as a warning
|
||||
// (the same amber as the sidebar PT pill) unless CPU is already critical.
|
||||
if (status === 'partial' || peakCpu >= WARN) return 'warn';
|
||||
return 'healthy';
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export interface NotificationItem {
|
||||
}
|
||||
|
||||
export interface StackStatusEntry {
|
||||
status: 'running' | 'exited' | 'unknown';
|
||||
status: 'running' | 'exited' | 'unknown' | 'partial';
|
||||
mainPort?: number;
|
||||
/** Unix seconds of the oldest running container (approximates stack uptime). */
|
||||
runningSince?: number;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { fetchForNode } from '@/lib/api';
|
||||
|
||||
export type StackStatus = 'running' | 'exited' | 'unknown';
|
||||
export type StackStatus = 'running' | 'exited' | 'unknown' | 'partial';
|
||||
|
||||
export interface StackStatusInfo {
|
||||
status: StackStatus;
|
||||
|
||||
Reference in New Issue
Block a user