mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore Allow updating or rebuilding one declared Compose service on multi-service stacks without recreating siblings, with recovery snapshots, health-gate observation, and prune holds for rollback images. Full-stack update paths and single-service UX stay unchanged. * fix: sanitize service-scoped update log messages for CodeQL * fix: address service-scoped update audit findings B-01 through B-07 * fix: complete service-scoped update audit metadata and surfaces * test: wrap Updates readiness tests for deploy-feedback context * fix: keep service recovery reachable without Deploy Progress Make failed service-gate recovery discoverable when Deploy Progress is disabled or dismissed, suppress stale image-scan notification side effects, normalize ComposeService line endings, and add focused regression coverage. * fix: resurface ContainersHealth density and expand on multi-service stacks Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
@@ -47,6 +47,8 @@ import type { NotificationItem } from '../dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
export interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -176,6 +178,14 @@ 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
|
||||
// ContainersHealth falls back to the flat single-service layout. Optional
|
||||
// so callers/tests that never deal in services can omit them.
|
||||
effectiveServices?: EffectiveServiceSpec[];
|
||||
serviceUpdateStatuses?: StackServiceUpdateStatus[];
|
||||
serviceUpdateInProgress?: { service: string; mode: 'update' | 'rebuild' } | null;
|
||||
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
||||
|
||||
// UI state setters
|
||||
setActiveTab: (tab: 'compose' | 'env' | 'files') => void;
|
||||
@@ -263,6 +273,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices = [],
|
||||
serviceUpdateStatuses = [],
|
||||
serviceUpdateInProgress = null,
|
||||
onRequestServiceUpdate,
|
||||
setActiveTab,
|
||||
setLogsMode,
|
||||
setEditingCompose,
|
||||
@@ -368,6 +382,11 @@ 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.
|
||||
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
||||
|
||||
// Below md, render the segmented full-screen mobile detail instead of the
|
||||
// desktop two-pane grid. All hooks above run unconditionally before this
|
||||
// branch so hook order stays stable across breakpoints.
|
||||
@@ -386,7 +405,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Command Center Card (identity + health strip). Hidden when
|
||||
the logs are expanded so the logs pane fills the column. */}
|
||||
{!logsExpanded && (
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 && !containersExpanded ? 'flex flex-col min-h-0 max-h-[42%]' : safeContainers.length > 1 && containersExpanded ? 'flex flex-col flex-1 min-h-0' : 'shrink-0'}`}>
|
||||
<Card className={`rounded-xl border-muted bg-card ${isMultiContainerLayout && !containersExpanded ? 'flex flex-col min-h-0 max-h-[42%]' : isMultiContainerLayout && containersExpanded ? 'flex flex-col flex-1 min-h-0' : 'shrink-0'}`}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -438,7 +457,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
panelStartedAt={panelStartedAt}
|
||||
variant="band"
|
||||
/>
|
||||
{safeContainers.length > 1 ? (
|
||||
{isMultiContainerLayout ? (
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<ContainersHealth
|
||||
@@ -450,6 +469,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
containersExpanded={containersExpanded}
|
||||
onToggleContainersExpand={toggleContainersExpand}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
@@ -467,6 +490,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -477,7 +504,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Logs Section (fills remaining left-column height). On multi-
|
||||
container stacks a min-h guarantees logs are never hidden.
|
||||
Hidden when containers are expanded to fill the column. */}
|
||||
{!containersExpanded && (safeContainers.length > 1 ? (
|
||||
{!containersExpanded && (isMultiContainerLayout ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
|
||||
@@ -62,6 +62,10 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices,
|
||||
serviceUpdateStatuses,
|
||||
serviceUpdateInProgress,
|
||||
onRequestServiceUpdate,
|
||||
setLogsMode,
|
||||
setActiveTab,
|
||||
setGitSourceOpen,
|
||||
@@ -225,6 +229,10 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={effectiveServices}
|
||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,8 @@ export function ShellOverlays({
|
||||
open={updateReadiness !== null}
|
||||
stackName={updateReadiness?.stackName ?? ''}
|
||||
nodeId={updateReadiness?.nodeId ?? null}
|
||||
serviceName={updateReadiness?.serviceName}
|
||||
mode={updateReadiness?.mode}
|
||||
onCancel={() => setUpdateReadiness(null)}
|
||||
onProceed={() => updateReadiness?.proceed()}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../Terminal', () => ({ default: () => null }));
|
||||
@@ -10,6 +11,8 @@ import { ContainersHealth } from '../editor-view-blocks';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
const LOCAL_NODE = { id: 1, type: 'local' } as Node;
|
||||
|
||||
@@ -221,3 +224,229 @@ describe('density toggle and summary strip', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('declared-service headers (multi-service only)', () => {
|
||||
function makeContainer(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
|
||||
return {
|
||||
Id: overrides.Id || 'abc',
|
||||
Names: overrides.Names || ['/app'],
|
||||
State: overrides.State || 'running',
|
||||
Status: overrides.Status || 'Up 1 hour',
|
||||
Image: overrides.Image || 'nginx',
|
||||
...overrides,
|
||||
} as unknown as ContainerInfo;
|
||||
}
|
||||
|
||||
function spec(overrides: Partial<EffectiveServiceSpec> = {}): EffectiveServiceSpec {
|
||||
return {
|
||||
name: 'web',
|
||||
declaredImage: 'nginx:latest',
|
||||
hasBuild: false,
|
||||
expectedReplicas: 1,
|
||||
dependsOn: [],
|
||||
hasHealthcheck: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function status(overrides: Partial<StackServiceUpdateStatus> = {}): StackServiceUpdateStatus {
|
||||
return {
|
||||
service: 'web',
|
||||
image: 'nginx:latest',
|
||||
hasUpdate: false,
|
||||
checkStatus: 'ok',
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders no declared-service header for a single effective service (unchanged single-service UX)', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Service: 'web' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec()]}
|
||||
/>,
|
||||
);
|
||||
// No grouped "X/Y running" service header; the flat per-container card
|
||||
// layout (with its own pre-existing "Service actions" menu) is unchanged.
|
||||
expect(screen.queryByText(/running$/)).toBeNull();
|
||||
expect(screen.getByLabelText('Service actions')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View logs' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one header per declared service and groups containers under it', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('db')).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
// Per-container service menu is hidden inside a multi-service header group.
|
||||
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the Update badge only for the service with a confirmed update', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
// db is not update-eligible (no image, no build) so it renders no
|
||||
// Update button/badge at all, keeping the "web" button unambiguous.
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: null })]}
|
||||
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Update', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses Rebuild wording with no badge for a build-backed service without a detected update', () => {
|
||||
const onRequestServiceUpdate = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web', hasBuild: true, declaredImage: null }), spec({ name: 'db' })]}
|
||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Update', { selector: 'span' })).toBeNull();
|
||||
const rebuildBtn = screen.getByRole('button', { name: /^Rebuild$/ });
|
||||
expect(rebuildBtn).toBeInTheDocument();
|
||||
fireEvent.click(rebuildBtn);
|
||||
expect(onRequestServiceUpdate).toHaveBeenCalledWith('web', 'rebuild');
|
||||
});
|
||||
|
||||
it('moves Start/Stop/Restart to the declared-service header menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
const serviceAction = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByLabelText('Service actions')[0]);
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Restart service' }));
|
||||
expect(serviceAction).toHaveBeenCalledWith('restart', 'web');
|
||||
});
|
||||
|
||||
it('still surfaces summary strip and density toggle on multi-service stacks', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
makeContainer({ Id: 'd2', Service: 'db', State: 'paused' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/3 containers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 up/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 paused/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Compact view' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles detailed sparklines on the multi-service path', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detailed view' }));
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('surfaces expand control on multi-service stacks when wired', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
containersExpanded={false}
|
||||
onToggleContainersExpand={onToggle}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand containers' }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ import StructuredLogViewer from '../StructuredLogViewer';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
|
||||
const extractUptime = (status: string | undefined): string | null => {
|
||||
if (!status) return null;
|
||||
@@ -304,6 +306,15 @@ export interface ContainersHealthProps {
|
||||
openLogViewer: (containerId: string, containerName: string) => void;
|
||||
openBashModal: (containerId: string, containerName: string) => void;
|
||||
serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise<void>;
|
||||
// Declared Compose services from the effective model. Multi-service
|
||||
// headers (owning Update/Rebuild + badge + Start/Stop/Restart) render only
|
||||
// when this has more than one entry; empty/single leaves the flat
|
||||
// container-card layout below untouched. Optional so callers that never
|
||||
// deal in services (and existing tests) can omit them.
|
||||
effectiveServices?: EffectiveServiceSpec[];
|
||||
serviceUpdateStatuses?: StackServiceUpdateStatus[];
|
||||
serviceUpdateInProgress?: { service: string; mode: 'update' | 'rebuild' } | null;
|
||||
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
||||
containersExpanded?: boolean;
|
||||
onToggleContainersExpand?: () => void;
|
||||
}
|
||||
@@ -319,16 +330,23 @@ export function ContainersHealth({
|
||||
openLogViewer,
|
||||
openBashModal,
|
||||
serviceAction,
|
||||
effectiveServices = [],
|
||||
serviceUpdateStatuses = [],
|
||||
serviceUpdateInProgress = null,
|
||||
onRequestServiceUpdate,
|
||||
containersExpanded,
|
||||
onToggleContainersExpand,
|
||||
}: ContainersHealthProps) {
|
||||
// Multi-service only (§12): 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);
|
||||
// Compact mode hides sparkline grids across all containers for a denser
|
||||
// list. Detailed mode (default) shows CPU / Mem / Net per container.
|
||||
const [density, setDensity] = useState<'compact' | 'detailed'>(
|
||||
safeContainers.length > 1 ? 'compact' : 'detailed',
|
||||
);
|
||||
safeContainers.length > 1 ? 'compact' : 'detailed',
|
||||
);
|
||||
useEffect(() => () => {
|
||||
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
|
||||
}, []);
|
||||
@@ -343,102 +361,85 @@ export function ContainersHealth({
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
|
||||
// Summary strip + density/expand toggles: multi-container stacks only,
|
||||
// whether the body is flat or grouped by declared service.
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
const densityToolbar = total > 1 ? (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
|
||||
Stats unavailable
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containerStatsError}</TooltipContent>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{onToggleContainersExpand && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContainersExpand}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={containersExpanded}
|
||||
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
|
||||
>
|
||||
{containersExpanded
|
||||
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary strip + density toggle appear only for multi-container
|
||||
stacks; single-container stacks keep the original layout. */}
|
||||
{safeContainers.length > 1 && (() => {
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{onToggleContainersExpand && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContainersExpand}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={containersExpanded}
|
||||
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
|
||||
>
|
||||
{containersExpanded
|
||||
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
</div>
|
||||
</div>
|
||||
) : 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).
|
||||
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
@@ -574,7 +575,7 @@ export function ContainersHealth({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{container.Service && (
|
||||
{!hideServiceMenu && container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -639,9 +640,126 @@ export function ContainersHealth({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
|
||||
Stats unavailable
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containerStatsError}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{densityToolbar}
|
||||
{isMultiService ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{effectiveServices.map(spec => {
|
||||
const group = safeContainers.filter(c => c.Service === spec.name);
|
||||
const status = serviceUpdateStatuses.find(s => s.service === spec.name);
|
||||
const busy = serviceUpdateInProgress?.service === spec.name;
|
||||
const hasUpdate = status?.hasUpdate === true;
|
||||
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
|
||||
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
|
||||
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
|
||||
const runningCount = group.filter(c => c.State === 'running').length;
|
||||
const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas';
|
||||
const replicaCopy = mode === 'rebuild'
|
||||
? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}`
|
||||
: `Updates all ${spec.expectedReplicas} ${replicaWord}`;
|
||||
return (
|
||||
<div key={spec.name} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">{spec.name}</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
{runningCount}/{spec.expectedReplicas} running
|
||||
</span>
|
||||
{hasUpdate && (
|
||||
<span className="rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showUpdateAction && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md px-2 max-md:h-11"
|
||||
onClick={() => onRequestServiceUpdate?.(spec.name, mode)}
|
||||
disabled={busy}
|
||||
>
|
||||
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
{busy
|
||||
? (mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
|
||||
: (mode === 'rebuild' ? 'Rebuild' : 'Update')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{replicaCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isServiceActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', spec.name)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', spec.name)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', spec.name)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{group.length > 0 ? (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
|
||||
{group.map(container => renderContainerCard(container, true))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-2 pl-3 font-mono text-xs text-muted-foreground">
|
||||
No containers running for this service.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => renderContainerCard(container, false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import { classifyFailedGate } from './failed-gate-recovery';
|
||||
import type { HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
type Gate = Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName'>;
|
||||
const gate = (over: Partial<Gate> = {}): Gate => ({ status: 'failed', nodeId: null, stackName: 'web', ...over });
|
||||
type Gate = Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName' | 'targetScope'>;
|
||||
const gate = (over: Partial<Gate> = {}): Gate => ({ status: 'failed', nodeId: null, stackName: 'web', targetScope: 'stack', ...over });
|
||||
|
||||
describe('classifyFailedGate', () => {
|
||||
it('skips when there is no gate', () => {
|
||||
@@ -43,4 +43,8 @@ describe('classifyFailedGate', () => {
|
||||
it('reports no-file when the node and file list match but no stack file matches the name yet', () => {
|
||||
expect(classifyFailedGate(gate({ nodeId: 3, stackName: 'web' }), 3, 3, ['other.yml'])).toEqual({ kind: 'no-file' });
|
||||
});
|
||||
|
||||
it('skips a failed service-scoped gate: the stack rollback recovery does not apply to a single service', () => {
|
||||
expect(classifyFailedGate(gate({ targetScope: 'service' }), null, null, ['web.yml'])).toEqual({ kind: 'skip' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ import type { HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
* file matches its name yet (the list may be mid-refresh). The caller leaves it
|
||||
* unhandled so the effect retries once the files land.
|
||||
* - `record`: record a recovery entry against `stackFile`.
|
||||
*
|
||||
* A service-scoped gate (`targetScope === 'service'`) always classifies as
|
||||
* `skip`: the stack-level rollback recovery this feeds (RecoveryChip/Panel,
|
||||
* keyed off the stack's own `rollback_target`) does not know how to restore a
|
||||
* single service, so routing a service gate's failure into it would offer a
|
||||
* rollback action that does not correspond to what actually happened.
|
||||
*/
|
||||
export type FailedGateOutcome =
|
||||
| { kind: 'skip' }
|
||||
@@ -24,12 +30,13 @@ export type FailedGateOutcome =
|
||||
| { kind: 'record'; stackFile: string };
|
||||
|
||||
export function classifyFailedGate(
|
||||
healthGate: Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName'> | null,
|
||||
healthGate: Pick<HealthGateUiState, 'status' | 'nodeId' | 'stackName' | 'targetScope'> | null,
|
||||
activeNodeId: number | null,
|
||||
filesNodeId: number | null,
|
||||
files: string[],
|
||||
): FailedGateOutcome {
|
||||
if (!healthGate || healthGate.status !== 'failed') return { kind: 'skip' };
|
||||
if (healthGate.targetScope === 'service') return { kind: 'skip' };
|
||||
// Record only while on the gate's node AND with that node's file list loaded.
|
||||
if (healthGate.nodeId !== activeNodeId || healthGate.nodeId !== filesNodeId) return { kind: 'skip' };
|
||||
const stackFile = files.find(f => f.replace(/\.(yml|yaml)$/, '') === healthGate.stackName);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
|
||||
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
|
||||
|
||||
@@ -30,6 +31,16 @@ export function useEditorViewState() {
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
// Declared-service facts for the loaded stack, from the effective Compose
|
||||
// model. Empty for a single-service stack, an older node without the
|
||||
// service-scoped-update capability, or a render failure; all three cases
|
||||
// fail closed to the legacy per-container layout (no declared-service
|
||||
// headers), so this array doubles as the multi-service gate.
|
||||
const [effectiveServices, setEffectiveServices] = useState<EffectiveServiceSpec[]>([]);
|
||||
// The declared service currently running a manual update/rebuild, so the
|
||||
// owning header can show a busy state. Only one at a time, mirroring the
|
||||
// single `loadingAction` for stack-level operations.
|
||||
const [serviceUpdateInProgress, setServiceUpdateInProgress] = useState<{ service: string; mode: 'update' | 'rebuild' } | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('compose');
|
||||
const [logsMode, setLogsMode] = useState<LogsMode>(readLogsMode);
|
||||
@@ -56,6 +67,8 @@ export function useEditorViewState() {
|
||||
envFiles, setEnvFiles,
|
||||
selectedEnvFile, setSelectedEnvFile,
|
||||
containers, setContainers,
|
||||
effectiveServices, setEffectiveServices,
|
||||
serviceUpdateInProgress, setServiceUpdateInProgress,
|
||||
activeTab, setActiveTab,
|
||||
logsMode, setLogsMode,
|
||||
gitSourceOpen, setGitSourceOpen,
|
||||
|
||||
@@ -122,13 +122,18 @@ export function useOverlayState() {
|
||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
||||
|
||||
// Pre-update readiness dialog. `proceed` runs the actual update when the
|
||||
// user confirms; opened by useStackActions.requestStackUpdate. `nodeId` is
|
||||
// captured at open time so both the readiness fetch and the update run against
|
||||
// the same node even if the active node changes while the dialog is open.
|
||||
// user confirms; opened by useStackActions.requestStackUpdate (full stack)
|
||||
// or requestServiceUpdate (a single declared service). `nodeId` is captured
|
||||
// at open time so both the readiness fetch and the update run against the
|
||||
// same node even if the active node changes while the dialog is open.
|
||||
// `serviceName`/`mode` are set only for a service-scoped update; absent
|
||||
// means the full-stack readiness check, unchanged from before.
|
||||
const [updateReadiness, setUpdateReadiness] = useState<{
|
||||
stackName: string;
|
||||
stackFile: string;
|
||||
nodeId: number | null;
|
||||
serviceName?: string;
|
||||
mode?: 'update' | 'rebuild';
|
||||
proceed: () => void;
|
||||
} | null>(null);
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
|
||||
setGitSourcePendingMap: vi.fn(),
|
||||
setComposeEtag: vi.fn(),
|
||||
setEnvEtag: vi.fn(),
|
||||
effectiveServices: [],
|
||||
setEffectiveServices: vi.fn(),
|
||||
serviceUpdateInProgress: null,
|
||||
setServiceUpdateInProgress: vi.fn(),
|
||||
};
|
||||
return { ...base, ...over } as unknown as EditorState;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/lib/hydrationTiming';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
|
||||
import { requestServiceUpdate as postServiceUpdate, requestServiceRestore as postServiceRestore } from '@/lib/serviceUpdate';
|
||||
import type { EffectiveServiceModelResult } from '@/types/effectiveServices';
|
||||
import type { useEditorViewState } from './useEditorViewState';
|
||||
import type { useStackListState } from './useStackListState';
|
||||
import type { useViewNavigationState } from './useViewNavigationState';
|
||||
@@ -153,6 +155,11 @@ interface UseStackActionsOptions {
|
||||
// Active node advertises guided external-network preflight. Absent capability
|
||||
// keeps legacy deploy (no GET). Advertised-but-broken fails closed.
|
||||
hasGuidedExternalNetworkPreflight?: boolean;
|
||||
// Active node advertises service-scoped updates. Gates both the
|
||||
// effective-services fetch (skipped entirely on an older node, so
|
||||
// effectiveServices stays empty and no declared-service headers render)
|
||||
// and the manual per-service update/rebuild action.
|
||||
hasServiceScopedUpdate?: boolean;
|
||||
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
|
||||
// or compose path); callers strip extensions when comparing to RBAC stack
|
||||
// names. Evaluated against the load target so post-load auto-edit is not
|
||||
@@ -379,6 +386,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard = false,
|
||||
hasGuidedExternalNetworkPreflight = false,
|
||||
hasServiceScopedUpdate = false,
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
} = options;
|
||||
@@ -493,6 +501,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setSelectedEnvFile('');
|
||||
editorState.setEnvExists(false);
|
||||
editorState.setContainers([]);
|
||||
editorState.setEffectiveServices([]);
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
editorState.setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -692,6 +702,32 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
// Declared-service facts for the multi-service headers. Skipped entirely
|
||||
// without the capability so an older remote node never sees the extra
|
||||
// request; a render failure or non-ok response also fails closed to an
|
||||
// empty list, which keeps the legacy single-service layout.
|
||||
const loadEffectiveServicesState = async (filename: string, signal?: AbortSignal) => {
|
||||
if (!hasServiceScopedUpdate) {
|
||||
editorState.setEffectiveServices([]);
|
||||
return;
|
||||
}
|
||||
const stackName = filename.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/effective-services`, { signal });
|
||||
if (signal?.aborted) return;
|
||||
if (!res.ok) {
|
||||
editorState.setEffectiveServices([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as EffectiveServiceModelResult;
|
||||
if (signal?.aborted) return;
|
||||
editorState.setEffectiveServices(data.renderable ? data.services : []);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return;
|
||||
editorState.setEffectiveServices([]);
|
||||
}
|
||||
};
|
||||
|
||||
const applyEditorRouteState = (tab: EditorTab) => {
|
||||
editorState.setActiveTab(tab);
|
||||
editorState.setEditingCompose(true);
|
||||
@@ -764,6 +800,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
};
|
||||
}
|
||||
await loadBackupState(filename, signal);
|
||||
await loadEffectiveServicesState(filename, signal);
|
||||
// Post-load auto-edit evaluates permission for the loaded target, not
|
||||
// the previously selected stack (selectedFile was just updated above).
|
||||
if (options?.startInComposeEdit && canEditStack(filename)) {
|
||||
@@ -789,6 +826,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setOriginalEnvContent('');
|
||||
editorState.setEnvEtag(null);
|
||||
editorState.setContainers([]);
|
||||
editorState.setEffectiveServices([]);
|
||||
return { ok: false };
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
@@ -1586,6 +1624,115 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
await run();
|
||||
};
|
||||
|
||||
// Single entry point for a manual service-scoped update/rebuild (declared-
|
||||
// service header, Updates view per-service Apply). Uses the same deploy-
|
||||
// feedback session as full-stack Update so progress streams and the health
|
||||
// gate is polled; siblings are not intentionally recreated.
|
||||
const requestServiceUpdate = async (
|
||||
stackFile: string,
|
||||
serviceName: string,
|
||||
mode: 'update' | 'rebuild' = 'update',
|
||||
): Promise<void> => {
|
||||
if (stackListState.isStackBusy(stackFile) || editorState.serviceUpdateInProgress) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const opNodeId = activeNode?.id ?? null;
|
||||
const run = async () => {
|
||||
editorState.setServiceUpdateInProgress({ service: serviceName, mode });
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'update', nodeId: opNodeId, serviceName }, async (started, ds) => {
|
||||
await started;
|
||||
const result = await postServiceUpdate({
|
||||
nodeId: opNodeId,
|
||||
stackName,
|
||||
serviceName,
|
||||
mode,
|
||||
deploySessionId: ds,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return { ok: false as const, errorMessage: result.error };
|
||||
}
|
||||
const verb = mode === 'rebuild' ? 'rebuilt' : 'updated';
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${serviceName}" ${verb}. Verifying health...`);
|
||||
} else {
|
||||
toast.success(`Service "${serviceName}" ${verb} successfully!`);
|
||||
}
|
||||
if (result.recheckWarning) toast.info(result.recheckWarning);
|
||||
stackListState.fetchImageUpdates();
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return {
|
||||
ok: true as const,
|
||||
healthGateId: result.observing ? result.healthGateId : null,
|
||||
recoveryId: result.recoveryId,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
stackListState.refreshStacks(true);
|
||||
}
|
||||
};
|
||||
if (hasUpdateGuard) {
|
||||
overlayState.setUpdateReadiness({
|
||||
stackName,
|
||||
stackFile,
|
||||
nodeId: opNodeId,
|
||||
serviceName,
|
||||
mode,
|
||||
proceed: () => {
|
||||
overlayState.setUpdateReadiness(null);
|
||||
void run();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await run();
|
||||
};
|
||||
|
||||
const requestServiceRestore = async (
|
||||
stackFile: string,
|
||||
serviceName: string,
|
||||
recoveryId: string,
|
||||
): Promise<void> => {
|
||||
if (stackListState.isStackBusy(stackFile) || editorState.serviceUpdateInProgress) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const opNodeId = activeNode?.id ?? null;
|
||||
editorState.setServiceUpdateInProgress({ service: serviceName, mode: 'update' });
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'update', nodeId: opNodeId, serviceName }, async (started, ds) => {
|
||||
await started;
|
||||
const result = await postServiceRestore({
|
||||
nodeId: opNodeId,
|
||||
stackName,
|
||||
serviceName,
|
||||
recoveryId,
|
||||
deploySessionId: ds,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return { ok: false as const, errorMessage: result.error };
|
||||
}
|
||||
if (result.healthGateId && result.observing) {
|
||||
toast.info(`Service "${serviceName}" restored. Verifying health...`);
|
||||
} else {
|
||||
toast.success(`Service "${serviceName}" restored successfully!`);
|
||||
}
|
||||
stackListState.fetchImageUpdates();
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return {
|
||||
ok: true as const,
|
||||
healthGateId: result.observing ? result.healthGateId : null,
|
||||
recoveryId: result.recoveryId,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
editorState.setServiceUpdateInProgress(null);
|
||||
stackListState.refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStack = async (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
@@ -1969,6 +2116,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
serviceAction,
|
||||
updateStack,
|
||||
requestStackUpdate,
|
||||
requestServiceUpdate,
|
||||
requestServiceRestore,
|
||||
deleteStack,
|
||||
attemptLeaveEditor,
|
||||
attemptPopstateNavigation,
|
||||
|
||||
Reference in New Issue
Block a user