mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
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:
@@ -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), []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user