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.
This commit is contained in:
Anso
2026-07-23 23:25:07 -04:00
committed by GitHub
parent ec0f59a85e
commit 524cc56d2f
14 changed files with 247 additions and 26 deletions
+5 -1
View File
@@ -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
<Frame>
<img src="/images/alerts-notifications/alert-panel.png" alt="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." />
+6 -4
View File
@@ -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.
<Frame>
<img src="/images/auto-heal-policies/sheet.png" alt="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" />
+4 -1
View File
@@ -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.
+4
View File
@@ -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}
@@ -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}
/>
</div>
{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}
@@ -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}
/>
</div>
@@ -229,6 +232,7 @@ export function MobileStackDetail(props: EditorViewProps) {
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
onOpenServiceMonitor={onOpenServiceMonitor}
serviceAction={serviceAction}
effectiveServices={effectiveServices}
serviceUpdateStatuses={serviceUpdateStatuses}
@@ -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 */}
@@ -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(
<ContainersHealth
safeContainers={[c]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
onOpenServiceMonitor={onOpenServiceMonitor}
/>,
);
await user.click(screen.getByRole('button', { name: 'Monitor web' }));
expect(onOpenServiceMonitor).toHaveBeenCalledWith('web');
});
it('hides Monitor when the container has no Service label', () => {
render(
<ContainersHealth
safeContainers={[container([{ PrivatePort: 80, PublicPort: 8080 }])]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
onOpenServiceMonitor={vi.fn()}
/>,
);
expect(screen.queryByRole('button', { name: /Monitor / })).toBeNull();
});
});
@@ -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);
});
});
@@ -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<typeof useStackMuteActions>;
/** 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 (
<div className="flex items-center gap-2 flex-wrap">
@@ -278,8 +282,14 @@ export function StackIdentityHeader({
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{onOpenMonitor && (
<DropdownMenuItem onClick={onOpenMonitor}>
<HeartPulse className="w-4 h-4 mr-2" strokeWidth={1.5} />
Monitor
</DropdownMenuItem>
)}
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{(canRollback || canScan || onOpenMonitor || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
@@ -308,6 +318,8 @@ export interface ContainersHealthProps {
activeNode: Node | null;
openLogViewer: (containerId: string, containerName: string) => 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<void>;
// 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({
<TooltipContent>View logs</TooltipContent>
</Tooltip>
</TooltipProvider>
{onOpenServiceMonitor && composeService && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
onClick={() => onOpenServiceMonitor(composeService)}
aria-label={`Monitor ${composeService}`}
>
<HeartPulse className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Monitor {composeService}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && (
<TooltipProvider>
<Tooltip>
@@ -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'));
@@ -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<StackMonitorState | null>(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), []);
@@ -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(
<StackAlertSheet
open
onOpenChange={() => {}}
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(
<StackAlertSheet
open
onOpenChange={() => {}}
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(
<StackAlertSheet
open
onOpenChange={() => {}}
stackName="my-stack"
initialTab="auto-heal"
initialService="api"
/>,
);
await waitFor(() => {
expect(screen.getByText('Add Policy')).toBeInTheDocument();
expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('api');
});
});
});
+47 -12
View File
@@ -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<MonitorTab>(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 (
<SystemSheet
@@ -160,13 +173,17 @@ export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'a
onTabChange={(id) => setActiveTab(id as MonitorTab)}
size="md"
>
{activeTab === 'alerts' && <AlertsTab stackName={stackName} />}
{activeTab === 'auto-heal' && <AutoHealTab stackName={stackName} open={open} />}
{activeTab === 'alerts' && (
<AlertsTab stackName={stackName} initialService={prefillService} />
)}
{activeTab === 'auto-heal' && (
<AutoHealTab stackName={stackName} open={open} initialService={prefillService} />
)}
</SystemSheet>
);
}
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<AutoHealPolicy[]>([]);
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<AutoHealPolicy[]>)
.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<string[]>)
.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);