From 524cc56d2ff43d1d38b737c0a8751509e72d29bc Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Jul 2026 23:25:07 -0400 Subject: [PATCH] feat: surface stack Monitor from header and service cards (#1693) Make Alerts and Auto-heal reachable from the stack More menu and container cards, with optional Compose service prefill in add forms. --- docs/features/alerts-notifications.mdx | 6 +- docs/features/auto-heal-policies.mdx | 10 ++-- docs/features/stack-management.mdx | 5 +- frontend/src/components/EditorLayout.tsx | 4 ++ .../components/EditorLayout/EditorView.tsx | 7 +++ .../EditorLayout/MobileStackDetail.tsx | 4 ++ .../components/EditorLayout/ShellOverlays.tsx | 1 + .../__tests__/ContainersHealth.test.tsx | 39 ++++++++++++ .../__tests__/StackIdentityHeader.test.tsx | 11 ++++ .../EditorLayout/editor-view-blocks.tsx | 38 +++++++++++- .../hooks/useOverlayState.test.ts | 13 ++++ .../EditorLayout/hooks/useOverlayState.ts | 16 +++-- .../src/components/StackAlertSheet.test.tsx | 60 +++++++++++++++++++ frontend/src/components/StackAlertSheet.tsx | 59 ++++++++++++++---- 14 files changed, 247 insertions(+), 26 deletions(-) diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index bc949794..97043bb4 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -167,7 +167,11 @@ Each stack carries its own set of threshold rules that fire when a metric stays Rules live on the node where the stack runs and are evaluated locally on a 30-second tick. -Open the rules editor by right-clicking a stack in the sidebar and choosing **Alerts**, or by pressing `A` while focused on a stack. +Open the rules editor from any of these entry points: + +- Stack header **More actions** → **Monitor** (Alerts tab) +- Container card **Monitor** (prefers that Compose service in add forms) +- Sidebar context menu **Alerts**, or press `A` while focused on a stack The Stack › PLEX › MONITOR sheet with the Alerts tab active, a green notification-channel banner under the NOTIFICATION CHANNELS heading, an ACTIVE RULES section listing 'CPU Usage (%) > 80' with the secondary line 'Trigger after 5m • Cooldown 60m', and an ADD NEW RULE form with Metric, Operator, Threshold, Duration, Cooldown fields and an Add Rule submit button. diff --git a/docs/features/auto-heal-policies.mdx b/docs/features/auto-heal-policies.mdx index 1b978208..0c8c77bd 100644 --- a/docs/features/auto-heal-policies.mdx +++ b/docs/features/auto-heal-policies.mdx @@ -28,10 +28,12 @@ Policies live next to your stack-level alert rules in the stack's **Monitor** sh ## Workflow -1. In the sidebar, right-click the stack you want to protect (or focus the stack and press **H**). -2. Click **Auto-Heal** in the **inspect** group. The stack's **Monitor** sheet opens directly on the **Auto-heal** tab. -3. In **Add new policy**, pick a **Service** (or leave the combobox on **All services** to cover every container in the stack) and tune the four thresholds. -4. Click **Add Policy**. The new policy appears in **Active policies** above the form, with its enable toggle already on. +1. Open the stack's **Monitor** sheet any of these ways: + - Stack header **More actions** → **Monitor**, then switch to the **Auto-heal** tab + - Container card **Monitor** (prefers that Compose service in **Add new policy**), then switch to **Auto-heal** + - Sidebar: right-click the stack (or focus it and press **H**) → **Auto-Heal** in the **inspect** group (opens directly on the **Auto-heal** tab) +2. In **Add new policy**, pick a **Service** (or leave the combobox on **All services** to cover every container in the stack) and tune the four thresholds. +3. Click **Add Policy**. The new policy appears in **Active policies** above the form, with its enable toggle already on. The Stack › plex › Monitor sheet on the Auto-heal tab, showing one policy scoped to the plex service in the Active policies section with its ON toggle, and the Add new policy form below with Service, Unhealthy for, Cooldown, Max restarts / hr, and Auto-disable after fields, plus the Add Policy button diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index be87bb04..08cdf81d 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -368,6 +368,7 @@ The stack header groups actions by frequency of use. The most common action is t | Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. | | Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. | | Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). | +| Overflow | **Monitor** | Opens Monitor sheet | Opens the stack **Monitor** sheet on the Alerts tab (alert rules and Auto-heal). Same sheet as sidebar **Alerts** / **Auto-Heal**. | | Overflow | **Mute** | Creates a mute rule | Quick presets to mute notifications, deploy-success noise, or monitor alerts for this stack, plus a link to manage its mute rules in full. See [Alerts and Notifications](/features/alerts-notifications). | | Overflow | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | @@ -377,6 +378,7 @@ The stack header groups actions by frequency of use. The most common action is t |-----------|--------|---------|--------------| | Primary | **Start** | `docker compose up -d` | Starts the stack. | | Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. | +| Overflow | **Monitor** | Opens Monitor sheet | Opens the stack **Monitor** sheet on the Alerts tab. | | Overflow | **Delete** | Removes files | Deletes the stack directory. | Use the sidebar context menu or `⌘↓` / `Ctrl+↓` for **Take down** on a stopped stack. @@ -414,8 +416,9 @@ Press `B` again or toggle the bulk mode button to leave bulk mode. ## Controlling a single service -Inside the stack detail view, each container card has a **Service actions** kebab on the right side. Use it to act on that service without touching the rest of the stack. +Inside the stack detail view, each container card has quick actions (View logs, Monitor, Open bash when available) and a **Service actions** kebab on the right side. Use them to act on that service without touching the rest of the stack. +- **Monitor**: opens the stack **Monitor** sheet with that Compose service preferred in the add forms. Shown only when the container reports a Compose service name. - **Restart service**: stops and starts all containers for that service. - **Stop service**: stops all containers for that service. Only shown when the service is running. - **Start service**: starts all containers for that service. Only shown when the service is stopped or exited. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index be11b41e..be3611eb 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -660,6 +660,10 @@ export default function EditorLayout() { changeEnvFile={stackActions.changeEnvFile} openLogViewer={stackActions.openLogViewer} openBashModal={stackActions.openBashModal} + onOpenMonitor={stackName ? () => overlayState.openAlertSheet(stackName) : undefined} + onOpenServiceMonitor={stackName + ? (serviceName) => overlayState.openAlertSheet(stackName, { serviceName }) + : undefined} serviceAction={stackActions.serviceAction} effectiveServices={effectiveServices} serviceUpdateStatuses={serviceUpdateStatuses} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 3f4d8504..d2ad501b 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -177,6 +177,8 @@ export interface EditorViewProps { // Container / service actions openLogViewer: (containerId: string, containerName: string) => void; openBashModal: (containerId: string, containerName: string) => void; + onOpenMonitor?: () => void; + onOpenServiceMonitor?: (serviceName: string) => void; serviceAction: ( action: 'start' | 'stop' | 'restart', serviceName: string, @@ -278,6 +280,8 @@ export function EditorView(props: EditorViewProps) { changeEnvFile, openLogViewer, openBashModal, + onOpenMonitor, + onOpenServiceMonitor, serviceAction, effectiveServices = [], serviceUpdateStatuses = [], @@ -447,6 +451,7 @@ export function EditorView(props: EditorViewProps) { showTakeDown={showTakeDown} isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} + onOpenMonitor={onOpenMonitor} /> {recoveryResult && loadingAction == null && ( @@ -484,6 +489,7 @@ export function EditorView(props: EditorViewProps) { activeNode={activeNode} openLogViewer={openLogViewer} openBashModal={openBashModal} + onOpenServiceMonitor={onOpenServiceMonitor} serviceAction={serviceAction} effectiveServices={effectiveServices} serviceUpdateStatuses={serviceUpdateStatuses} @@ -508,6 +514,7 @@ export function EditorView(props: EditorViewProps) { activeNode={activeNode} openLogViewer={openLogViewer} openBashModal={openBashModal} + onOpenServiceMonitor={onOpenServiceMonitor} serviceAction={serviceAction} effectiveServices={effectiveServices} serviceUpdateStatuses={serviceUpdateStatuses} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index dce74aa1..c2d409dd 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -61,6 +61,8 @@ export function MobileStackDetail(props: EditorViewProps) { changeEnvFile, openLogViewer, openBashModal, + onOpenMonitor, + onOpenServiceMonitor, serviceAction, effectiveServices = [], serviceUpdateStatuses, @@ -159,6 +161,7 @@ export function MobileStackDetail(props: EditorViewProps) { showTakeDown={showTakeDown} isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} + onOpenMonitor={onOpenMonitor} /> @@ -229,6 +232,7 @@ export function MobileStackDetail(props: EditorViewProps) { activeNode={activeNode} openLogViewer={openLogViewer} openBashModal={openBashModal} + onOpenServiceMonitor={onOpenServiceMonitor} serviceAction={serviceAction} effectiveServices={effectiveServices} serviceUpdateStatuses={serviceUpdateStatuses} diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index b7dfb8de..e0e8cc1c 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -117,6 +117,7 @@ export function ShellOverlays({ onOpenChange={(open) => { if (!open) closeStackMonitor(); }} stackName={stackMonitor?.stackName ?? ''} initialTab={stackMonitor?.tab ?? 'alerts'} + initialService={stackMonitor?.serviceName} /> {/* Pre-update readiness check */} diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index 4d2dd253..083e9283 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -509,4 +509,43 @@ describe('containers load states', () => { await user.click(screen.getByRole('button', { name: /retry/i })); expect(onRetry).toHaveBeenCalledTimes(1); }); + + it('shows Monitor when Service is set and calls onOpenServiceMonitor', async () => { + const onOpenServiceMonitor = vi.fn(); + const user = userEvent.setup(); + const c = { ...container([{ PrivatePort: 80, PublicPort: 8080 }]), Service: 'web' } as ContainerInfo; + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Monitor web' })); + expect(onOpenServiceMonitor).toHaveBeenCalledWith('web'); + }); + + it('hides Monitor when the container has no Service label', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /Monitor / })).toBeNull(); + }); }); diff --git a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx index f61b63f7..43e98509 100644 --- a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx @@ -103,4 +103,15 @@ describe('StackIdentityHeader', () => { expect(screen.queryByRole('menuitem', { name: /Take down/i })).toBeNull(); }); + + it('shows Monitor in More actions and calls onOpenMonitor', async () => { + const user = userEvent.setup(); + const onOpenMonitor = vi.fn(); + renderHeader({ onOpenMonitor, backupInfo: { exists: false, timestamp: null } }); + + await user.click(screen.getByRole('button', { name: 'More actions' })); + await user.click(screen.getByRole('menuitem', { name: 'Monitor' })); + + expect(onOpenMonitor).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index 496ab00f..09735b60 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -22,7 +22,8 @@ import { Maximize2, Minimize2, AlertCircle, - RefreshCw + RefreshCw, + HeartPulse, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; @@ -144,6 +145,8 @@ export interface StackIdentityHeaderProps { /** True when this stack is the running Sencho instance on the active node. */ isSelfStack?: boolean; stackMuteActions?: ReturnType; + /** Opens the stack Monitor sheet on the Alerts tab. */ + onOpenMonitor?: () => void; } // Breadcrumb + serif title + state pill + action bar. The action buttons grow @@ -170,6 +173,7 @@ export function StackIdentityHeader({ showTakeDown, isSelfStack = false, stackMuteActions, + onOpenMonitor, }: StackIdentityHeaderProps) { const selfProtected = isSelfStack; return ( @@ -206,7 +210,7 @@ export function StackIdentityHeader({ const canScan = trivy.available && isAdmin; const canMute = stackMuteActions?.canMute ?? false; const hasOverflowExtras = canRollback || canScan; - const hasOverflow = hasOverflowExtras || canDelete || canMute; + const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor; if (!canDeploy && !hasOverflow) return null; return (
@@ -278,8 +282,14 @@ export function StackIdentityHeader({ {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} + {onOpenMonitor && ( + + + Monitor + + )} {stackMuteActions && } - {(canRollback || canScan || stackMuteActions?.canMute) && canDelete && } + {(canRollback || canScan || onOpenMonitor || stackMuteActions?.canMute) && canDelete && } {canDelete && ( void; openBashModal: (containerId: string, containerName: string) => void; + /** Opens Monitor (Alerts tab); preselects the Compose service in add forms when listed. */ + onOpenServiceMonitor?: (serviceName: string) => void; serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise; // Declared Compose services from the effective model. Multi-service // headers (owning Update/Rebuild + badge + Start/Stop/Restart) render only @@ -335,6 +347,7 @@ export function ContainersHealth({ activeNode, openLogViewer, openBashModal, + onOpenServiceMonitor, serviceAction, effectiveServices = [], serviceUpdateStatuses = [], @@ -474,6 +487,7 @@ export function ContainersHealth({ : ''; const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; + const composeService = container.Service; const isActive = container.State === 'running' || container.State === 'paused'; const health = container.healthStatus; const uptime = isActive ? extractUptime(container.Status) : null; @@ -565,6 +579,24 @@ export function ContainersHealth({ View logs + {onOpenServiceMonitor && composeService && ( + + + + + + Monitor {composeService} + + + )} {isAdmin && ( diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts index 8438a881..2da64452 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts @@ -74,6 +74,19 @@ describe('useOverlayState', () => { expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'alerts' }); }); + it.each([ + ['openAlertSheet', 'alerts' as const], + ['openAutoHeal', 'auto-heal' as const], + ])('%s can carry a serviceName for form preselect', (openFn, tab) => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current[openFn as 'openAlertSheet' | 'openAutoHeal']('web-stack', { serviceName: 'api' })); + expect(result.current.stackMonitor).toEqual({ + stackName: 'web-stack', + tab, + serviceName: 'api', + }); + }); + it('openAutoHeal opens stack monitor on the auto-heal tab', () => { const { result } = renderHook(() => useOverlayState()); act(() => result.current.openAutoHeal('web-stack')); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 8ddfabd5..86aeea97 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -25,6 +25,12 @@ type PolicyBlock = { }; type Container = { id: string; name: string }; +type StackMonitorState = { + stackName: string; + tab: 'alerts' | 'auto-heal'; + serviceName?: string; +}; + // Kept here (not in useStackActions) so overlay state can hold load options // without a circular type import between the two hooks. export type LoadFileOptions = { @@ -109,12 +115,12 @@ export function useOverlayState() { return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); }, [openLogViewer]); // openLogViewer is stable (useCallback with empty deps) - const [stackMonitor, setStackMonitor] = useState<{ stackName: string; tab: 'alerts' | 'auto-heal' } | null>(null); - const openAlertSheet = useCallback((stackName: string) => { - setStackMonitor({ stackName, tab: 'alerts' }); + const [stackMonitor, setStackMonitor] = useState(null); + const openAlertSheet = useCallback((stackName: string, opts?: { serviceName?: string }) => { + setStackMonitor({ stackName, tab: 'alerts', serviceName: opts?.serviceName }); }, []); - const openAutoHeal = useCallback((stackName: string) => { - setStackMonitor({ stackName, tab: 'auto-heal' }); + const openAutoHeal = useCallback((stackName: string, opts?: { serviceName?: string }) => { + setStackMonitor({ stackName, tab: 'auto-heal', serviceName: opts?.serviceName }); }, []); const closeStackMonitor = useCallback(() => setStackMonitor(null), []); diff --git a/frontend/src/components/StackAlertSheet.test.tsx b/frontend/src/components/StackAlertSheet.test.tsx index 46f413cf..a598be3d 100644 --- a/frontend/src/components/StackAlertSheet.test.tsx +++ b/frontend/src/components/StackAlertSheet.test.tsx @@ -245,4 +245,64 @@ describe('StackAlertSheet Alerts tab', () => { // New node's list has only worker, so the api-targeted rule is missing. await waitFor(() => expect(screen.getByText(/Not in compose/i)).toBeInTheDocument()); }); + + it('prefills the Service combobox from initialService when listed', async () => { + mockHappyPath(['api', 'database']); + render( + {}} + stackName="my-stack" + initialService="api" + />, + ); + + await waitFor(() => { + expect(screen.getByText('Add Rule')).toBeInTheDocument(); + expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('api'); + }); + }); + + it('ignores Alerts initialService when service-scoped capability is absent', async () => { + nodeState.activeNodeMeta = { version: '0.90.0', capabilities: [] }; + mockHappyPath(['api', 'database']); + render( + {}} + stackName="my-stack" + initialService="api" + />, + ); + + await waitFor(() => expect(screen.getByText('Add Rule')).toBeInTheDocument()); + expect(screen.queryByText('All services')).toBeNull(); + expect( + mockedFetch.mock.calls.some(([url]) => String(url).includes('/services')), + ).toBe(false); + }); + + it('prefills Auto-heal Service from initialService when listed', async () => { + mockedFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auto-heal/policies')) return jsonRes([]); + if (url.includes('/services')) return jsonRes(['api', 'database']); + return jsonRes(null, false); + }); + + render( + {}} + stackName="my-stack" + initialTab="auto-heal" + initialService="api" + />, + ); + + await waitFor(() => { + expect(screen.getByText('Add Policy')).toBeInTheDocument(); + expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('api'); + }); + }); }); diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index fbe70db0..bd1766d2 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -78,6 +78,8 @@ interface StackAlertSheetProps { onOpenChange: (open: boolean) => void; stackName: string; initialTab?: MonitorTab; + /** Prefill Add-form service comboboxes when opening from a service card. */ + initialService?: string; } interface AgentStatus { @@ -124,6 +126,10 @@ function actionColorClass(action: AutoHealHistoryEntry['action']): string { return 'text-muted-foreground'; } +function preselectListedService(initialService: string | undefined, names: string[]): string { + return initialService && names.includes(initialService) ? initialService : ''; +} + function actionLabel(action: AutoHealHistoryEntry['action']): string { switch (action) { case 'restarted': return 'Restarted'; @@ -136,7 +142,13 @@ function actionLabel(action: AutoHealHistoryEntry['action']): string { } } -export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) { +export function StackAlertSheet({ + open, + onOpenChange, + stackName, + initialTab = 'alerts', + initialService, +}: StackAlertSheetProps) { const [activeTab, setActiveTab] = useState(initialTab); useEffect(() => { @@ -147,6 +159,7 @@ export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'a { id: 'alerts', label: 'Alerts' }, { id: 'auto-heal', label: 'Auto-heal' }, ]; + const prefillService = open ? initialService : undefined; return ( } - {activeTab === 'auto-heal' && } + {activeTab === 'alerts' && ( + + )} + {activeTab === 'auto-heal' && ( + + )} ); } -function AlertsTab({ stackName }: { stackName: string }) { +function AlertsTab({ stackName, initialService }: { stackName: string; initialService?: string }) { const { isAdmin } = useAuth(); const { activeNode, activeNodeMeta } = useNodes(); const isRemote = activeNode?.type === 'remote'; @@ -211,7 +228,9 @@ function AlertsTab({ stackName }: { stackName: string }) { const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`); if (!res.ok) throw new Error(`services ${res.status}`); const names = await res.json() as string[]; - if (!cancelled) setServicesState({ status: 'success', options: names }); + if (cancelled) return; + setServicesState({ status: 'success', options: names }); + setService(preselectListedService(initialService, names)); } catch (e) { console.error('[StackAlertSheet] Failed to fetch stack services', e); if (!cancelled) setServicesState({ status: 'error' }); @@ -219,7 +238,7 @@ function AlertsTab({ stackName }: { stackName: string }) { })(); return () => { cancelled = true; }; - }, [stackName, activeNode?.id, canScopeService]); + }, [stackName, activeNode?.id, canScopeService, initialService]); const fetchAlerts = async () => { try { @@ -567,7 +586,15 @@ function AlertsTab({ stackName }: { stackName: string }) { ); } -function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) { +function AutoHealTab({ + stackName, + open, + initialService, +}: { + stackName: string; + open: boolean; + initialService?: string; +}) { const { isAdmin } = useAuth(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); @@ -584,17 +611,25 @@ function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) useEffect(() => { if (!open || !stackName) return; setLoading(true); + setService(''); apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) .then(res => res.json() as Promise) .then(data => setPolicies(data)) .catch(() => toast.error('Failed to load auto-heal policies.')) .finally(() => setLoading(false)); - apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`) - .then(res => res.json() as Promise) - .then(names => setServiceOptions(names.map(n => ({ value: n, label: n })))) - .catch(() => { /* services list is optional, silently skip */ }); - }, [open, stackName]); + void (async () => { + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`); + if (!res.ok) throw new Error(`services ${res.status}`); + const names = await res.json() as string[]; + setServiceOptions(names.map(n => ({ value: n, label: n }))); + setService(preselectListedService(initialService, names)); + } catch (e) { + console.error('[StackAlertSheet] Failed to fetch stack services', e); + } + })(); + }, [open, stackName, initialService]); const handleToggle = async (id: number, enabled: boolean) => { setSaving(true);