mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user