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
+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);