fix: hoist DetailPanel into AppShell detail slot for proper slide-in
DetailPanel is a flex sibling that slides in from the right — it must be rendered at the AppShell level via the detail prop, not inside the page content. Add DetailPanelContext so pages can push their panel content up to LayoutShell, which passes it to AppShell.detail. Applied to Dashboard (exchange detail) and AgentHealth (instance detail). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
ui/src/components/DetailPanelContext.tsx
Normal file
23
ui/src/components/DetailPanelContext.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
|
||||
interface DetailPanelContextValue {
|
||||
detail: ReactNode;
|
||||
setDetail: (node: ReactNode) => void;
|
||||
}
|
||||
|
||||
const DetailPanelContext = createContext<DetailPanelContextValue | null>(null);
|
||||
|
||||
export function DetailPanelProvider({ children }: { children: ReactNode }) {
|
||||
const [detail, setDetail] = useState<ReactNode>(null);
|
||||
return (
|
||||
<DetailPanelContext.Provider value={{ detail, setDetail }}>
|
||||
{children}
|
||||
</DetailPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDetailPanelSlot(): DetailPanelContextValue {
|
||||
const ctx = useContext(DetailPanelContext);
|
||||
if (!ctx) throw new Error('useDetailPanelSlot must be used within DetailPanelProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { DetailPanelProvider, useDetailPanelSlot } from './DetailPanelContext';
|
||||
|
||||
function healthToColor(health: string): string {
|
||||
switch (health) {
|
||||
@@ -68,6 +69,7 @@ function LayoutContent() {
|
||||
const { data: agents } = useAgents();
|
||||
const { username, logout } = useAuthStore();
|
||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||
const { detail } = useDetailPanelSlot();
|
||||
|
||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
@@ -122,6 +124,7 @@ function LayoutContent() {
|
||||
apps={sidebarApps}
|
||||
/>
|
||||
}
|
||||
detail={detail}
|
||||
>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
@@ -146,7 +149,9 @@ export function LayoutShell() {
|
||||
<ToastProvider>
|
||||
<CommandPaletteProvider>
|
||||
<GlobalFilterProvider>
|
||||
<LayoutContent />
|
||||
<DetailPanelProvider>
|
||||
<LayoutContent />
|
||||
</DetailPanelProvider>
|
||||
</GlobalFilterProvider>
|
||||
</CommandPaletteProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||
@@ -9,6 +9,7 @@ import styles from './AgentHealth.module.css';
|
||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import { useDetailPanelSlot } from '../../components/DetailPanelContext';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -218,6 +219,7 @@ export default function AgentHealth() {
|
||||
const { appId } = useParams();
|
||||
const { data: agents } = useAgents(undefined, appId);
|
||||
const { data: events } = useAgentEvents(appId);
|
||||
const { setDetail } = useDetailPanelSlot();
|
||||
|
||||
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
@@ -503,18 +505,41 @@ export default function AgentHealth() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => {
|
||||
setPanelOpen(false);
|
||||
setSelectedInstance(null);
|
||||
}}
|
||||
title={selectedInstance.name ?? selectedInstance.id}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
{/* Detail panel — hoisted to AppShell via context */}
|
||||
<AgentDetailPanelSlot
|
||||
open={panelOpen}
|
||||
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
|
||||
selectedInstance={selectedInstance}
|
||||
detailTabs={detailTabs}
|
||||
setDetail={setDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDetailPanelSlot({
|
||||
open, onClose, selectedInstance, detailTabs, setDetail,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
selectedInstance: AgentInstance | null
|
||||
detailTabs: any
|
||||
setDetail: (node: React.ReactNode) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!selectedInstance) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setDetail(
|
||||
<DetailPanel
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={selectedInstance.name ?? selectedInstance.id}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
);
|
||||
return () => setDetail(null);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
DataTable,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||
import { useDetailPanelSlot } from '../../components/DetailPanelContext'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||
@@ -174,6 +175,7 @@ const SHORTCUTS = [
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { setDetail } = useDetailPanelSlot()
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const [sortField, setSortField] = useState<string>('startTime')
|
||||
@@ -414,95 +416,128 @@ export default function Dashboard() {
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedRow && detail && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||
>
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
||||
>
|
||||
Open full details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Overview</div>
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(detail.status)} />
|
||||
<span>{statusLabel(detail.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Route</span>
|
||||
<span>{detail.routeId}</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{errorMsg && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Errors</div>
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>{errorClass}</div>
|
||||
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{routeNodes.length > 0 ? (
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{flatProcs.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={flatProcs}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
{/* Detail panel — hoisted to AppShell via context */}
|
||||
<DashboardDetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
selectedRow={selectedRow}
|
||||
detail={detail}
|
||||
errorClass={errorClass}
|
||||
errorMsg={errorMsg}
|
||||
routeNodes={routeNodes}
|
||||
flatProcs={flatProcs}
|
||||
navigate={navigate}
|
||||
setDetail={setDetail}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders the DetailPanel into the AppShell detail slot via context. */
|
||||
function DashboardDetailPanel({
|
||||
open, onClose, selectedRow, detail, errorClass, errorMsg, routeNodes, flatProcs, navigate, setDetail,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
selectedRow: Row | undefined
|
||||
detail: any
|
||||
errorClass: string
|
||||
errorMsg: string
|
||||
routeNodes: RouteNode[]
|
||||
flatProcs: any[]
|
||||
navigate: (path: string) => void
|
||||
setDetail: (node: React.ReactNode) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!selectedRow || !detail) {
|
||||
setDetail(null)
|
||||
return
|
||||
}
|
||||
setDetail(
|
||||
<DetailPanel
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||
>
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
||||
>
|
||||
Open full details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Overview</div>
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(detail.status)} />
|
||||
<span>{statusLabel(detail.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Route</span>
|
||||
<span>{detail.routeId}</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Errors</div>
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>{errorClass}</div>
|
||||
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{routeNodes.length > 0 ? (
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{flatProcs.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={flatProcs}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)
|
||||
return () => setDetail(null)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user