fix: OpenSearch status field mismatch, adopt RouteFlow flows prop
Fix admin OpenSearch page always showing "Disconnected" by aligning frontend field names (reachable/nodeCount/host) with backend DTO. Update design system to v0.1.10 and adopt the new multi-flow RouteFlow API — error-handler nodes now render as labeled segments with error variant instead of relying on legacy auto-separation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.0.0-snapshot.20260325.499c86b",
|
"@cameleer/design-system": "^0.1.10",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -276,9 +276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cameleer/design-system": {
|
"node_modules/@cameleer/design-system": {
|
||||||
"version": "0.0.0-snapshot.20260325.499c86b",
|
"version": "0.1.10",
|
||||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.0-snapshot.20260325.499c86b/design-system-0.0.0-snapshot.20260325.499c86b.tgz",
|
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.10/design-system-0.1.10.tgz",
|
||||||
"integrity": "sha512-uiBdWYTT0wzIgL8QX21oHyb7xjeepnXvGAl/YHapd1o4u+GuXuB23kcyECurM/OTUN4dM7RGjGBp46Mbe8xcIQ==",
|
"integrity": "sha512-1gQ8SM7AEgYv3UQNlC7qhcQ0HSIEFkYA34AV/hjDuFUQV3xrPI0g/p/YrJ5W4KIRb2YvnNqDPNgb6kLANufqRQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
|
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.0.0-snapshot.20260325.499c86b",
|
"@cameleer/design-system": "^0.1.10",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useRefreshInterval } from '../use-refresh-interval';
|
|||||||
// ── Types ──────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OpenSearchStatus {
|
export interface OpenSearchStatus {
|
||||||
connected: boolean;
|
reachable: boolean;
|
||||||
clusterHealth: string;
|
clusterHealth: string;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
numberOfNodes: number;
|
nodeCount: number;
|
||||||
url: string;
|
host: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineStats {
|
export interface PipelineStats {
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export default function OpenSearchAdminPage() {
|
|||||||
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
|
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
<StatCard label="Status" value={status?.reachable ? 'Connected' : 'Disconnected'} accent={status?.reachable ? 'success' : 'error'} />
|
||||||
<StatCard label="Health" value={status?.clusterHealth ?? '—'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
<StatCard label="Health" value={status?.clusterHealth ?? '—'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
||||||
<StatCard label="Version" value={status?.version ?? '—'} />
|
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||||
<StatCard label="Nodes" value={status?.numberOfNodes ?? 0} />
|
<StatCard label="Nodes" value={status?.nodeCount ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pipeline && (
|
{pipeline && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
useGlobalFilters,
|
useGlobalFilters,
|
||||||
} from '@cameleer/design-system'
|
} from '@cameleer/design-system'
|
||||||
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
|
import type { Column, KpiItem } from '@cameleer/design-system'
|
||||||
import {
|
import {
|
||||||
useSearchExecutions,
|
useSearchExecutions,
|
||||||
useExecutionStats,
|
useExecutionStats,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '../../api/queries/executions'
|
} from '../../api/queries/executions'
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import type { ExecutionSummary } from '../../api/types'
|
import type { ExecutionSummary } from '../../api/types'
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||||
@@ -355,9 +355,9 @@ export default function Dashboard() {
|
|||||||
: (detail.children ?? [])
|
: (detail.children ?? [])
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const routeNodes: RouteNode[] = useMemo(() => {
|
const routeFlows = useMemo(() => {
|
||||||
if (diagram?.nodes) {
|
if (diagram?.nodes) {
|
||||||
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
|
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes || [], procList)).flows
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}, [diagram, procList])
|
}, [diagram, procList])
|
||||||
@@ -475,8 +475,8 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||||
{routeNodes.length > 0 ? (
|
{routeFlows.length > 0 ? (
|
||||||
<RouteFlow nodes={routeNodes} />
|
<RouteFlow flows={routeFlows} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ProcessorStep, RouteNode, NodeBadge } from '@cameleer/design-syste
|
|||||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||||
import { useCorrelationChain } from '../../api/queries/correlation'
|
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'
|
||||||
import { useTracingStore } from '../../stores/tracing-store'
|
import { useTracingStore } from '../../stores/tracing-store'
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'
|
||||||
import styles from './ExchangeDetail.module.css'
|
import styles from './ExchangeDetail.module.css'
|
||||||
@@ -128,32 +128,36 @@ export default function ExchangeDetail() {
|
|||||||
const inputBody = snapshot?.inputBody ?? null
|
const inputBody = snapshot?.inputBody ?? null
|
||||||
const outputBody = snapshot?.outputBody ?? null
|
const outputBody = snapshot?.outputBody ?? null
|
||||||
|
|
||||||
// Build RouteFlow nodes from diagram + execution data
|
// Build RouteFlow nodes from diagram + execution data, split into flow segments
|
||||||
const routeNodes: RouteNode[] = useMemo(() => {
|
const { routeFlows, flowIndexMap } = useMemo(() => {
|
||||||
|
let nodes: RouteNode[]
|
||||||
if (diagram?.nodes) {
|
if (diagram?.nodes) {
|
||||||
// Flatten processors to build diagramNodeId → processorId lookup
|
// Flatten processors to build diagramNodeId → processorId lookup
|
||||||
const flatProcs: Array<{ diagramNodeId?: string; processorId?: string }> = []
|
const flatProcs: Array<{ diagramNodeId?: string; processorId?: string }> = []
|
||||||
function flattenProcs(nodes: any[]) {
|
function flattenProcs(list: any[]) {
|
||||||
for (const n of nodes) { flatProcs.push(n); if (n.children) flattenProcs(n.children) }
|
for (const n of list) { flatProcs.push(n); if (n.children) flattenProcs(n.children) }
|
||||||
}
|
}
|
||||||
flattenProcs(procList)
|
flattenProcs(procList)
|
||||||
const pidLookup = new Map(flatProcs
|
const pidLookup = new Map(flatProcs
|
||||||
.filter(p => p.diagramNodeId && p.processorId)
|
.filter(p => p.diagramNodeId && p.processorId)
|
||||||
.map(p => [p.diagramNodeId!, p.processorId!]))
|
.map(p => [p.diagramNodeId!, p.processorId!]))
|
||||||
|
|
||||||
return mapDiagramToRouteNodes(diagram.nodes, procList).map((node, i) => ({
|
nodes = mapDiagramToRouteNodes(diagram.nodes, procList).map((node, i) => ({
|
||||||
...node,
|
...node,
|
||||||
badges: badgesFor(pidLookup.get(diagram.nodes[i]?.id ?? '') ?? diagram.nodes[i]?.id ?? ''),
|
badges: badgesFor(pidLookup.get(diagram.nodes[i]?.id ?? '') ?? diagram.nodes[i]?.id ?? ''),
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
// Fallback: build from processor list
|
||||||
|
nodes = processors.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
type: 'process' as RouteNode['type'],
|
||||||
|
durationMs: p.durationMs,
|
||||||
|
status: p.status,
|
||||||
|
badges: badgesFor(p.name),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
// Fallback: build from processor list
|
const { flows, indexMap } = toFlowSegments(nodes)
|
||||||
return processors.map((p) => ({
|
return { routeFlows: flows, flowIndexMap: indexMap }
|
||||||
name: p.name,
|
|
||||||
type: 'process' as RouteNode['type'],
|
|
||||||
durationMs: p.durationMs,
|
|
||||||
status: p.status,
|
|
||||||
badges: badgesFor(p.name),
|
|
||||||
}))
|
|
||||||
}, [diagram, processors, procList, tracedMap])
|
}, [diagram, processors, procList, tracedMap])
|
||||||
|
|
||||||
// ProcessorId lookup: timeline index → processorId
|
// ProcessorId lookup: timeline index → processorId
|
||||||
@@ -388,13 +392,14 @@ export default function ExchangeDetail() {
|
|||||||
<InfoCallout>No processor data available</InfoCallout>
|
<InfoCallout>No processor data available</InfoCallout>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
routeNodes.length > 0 ? (
|
routeFlows.length > 0 ? (
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
nodes={routeNodes}
|
flows={routeFlows}
|
||||||
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
onNodeClick={(_node, index) => setSelectedProcessorIndex(flowIndexMap[index] ?? index)}
|
||||||
selectedIndex={activeIndex}
|
selectedIndex={flowIndexMap.indexOf(activeIndex)}
|
||||||
getActions={(_node, index) => {
|
getActions={(_node, index) => {
|
||||||
const pid = flowProcessorIds[index]
|
const origIdx = flowIndexMap[index] ?? index
|
||||||
|
const pid = flowProcessorIds[origIdx]
|
||||||
if (!pid || !detail?.applicationName) return []
|
if (!pid || !detail?.applicationName) return []
|
||||||
return [{
|
return [{
|
||||||
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',
|
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useDiagramByRoute } from '../../api/queries/diagrams';
|
|||||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
||||||
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
||||||
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping';
|
||||||
import styles from './RouteDetail.module.css';
|
import styles from './RouteDetail.module.css';
|
||||||
|
|
||||||
// ── Row types ────────────────────────────────────────────────────────────────
|
// ── Row types ────────────────────────────────────────────────────────────────
|
||||||
@@ -321,9 +321,9 @@ export default function RouteDetail() {
|
|||||||
}, [health]);
|
}, [health]);
|
||||||
|
|
||||||
// Route flow from diagram
|
// Route flow from diagram
|
||||||
const diagramNodes = useMemo(() => {
|
const diagramFlows = useMemo(() => {
|
||||||
if (!diagram?.nodes) return [];
|
if (!diagram?.nodes) return [];
|
||||||
return mapDiagramToRouteNodes(diagram.nodes, []);
|
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes, [])).flows;
|
||||||
}, [diagram]);
|
}, [diagram]);
|
||||||
|
|
||||||
// Processor table rows
|
// Processor table rows
|
||||||
@@ -458,8 +458,8 @@ export default function RouteDetail() {
|
|||||||
<div className={styles.diagramStatsGrid}>
|
<div className={styles.diagramStatsGrid}>
|
||||||
<div className={styles.diagramPane}>
|
<div className={styles.diagramPane}>
|
||||||
<div className={styles.paneTitle}>Route Diagram</div>
|
<div className={styles.paneTitle}>Route Diagram</div>
|
||||||
{diagramNodes.length > 0 ? (
|
{diagramFlows.length > 0 ? (
|
||||||
<RouteFlow nodes={diagramNodes} />
|
<RouteFlow flows={diagramFlows} />
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.emptyText}>
|
<div className={styles.emptyText}>
|
||||||
No diagram available for this route.
|
No diagram available for this route.
|
||||||
@@ -497,12 +497,12 @@ export default function RouteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route Flow section */}
|
{/* Route Flow section */}
|
||||||
{diagramNodes.length > 0 && (
|
{diagramFlows.length > 0 && (
|
||||||
<div className={styles.routeFlowSection}>
|
<div className={styles.routeFlowSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
<span className={styles.tableTitle}>Route Flow</span>
|
<span className={styles.tableTitle}>Route Flow</span>
|
||||||
</div>
|
</div>
|
||||||
<RouteFlow nodes={diagramNodes} />
|
<RouteFlow flows={diagramFlows} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RouteNode } from '@cameleer/design-system';
|
import type { RouteNode, FlowSegment } from '@cameleer/design-system';
|
||||||
|
|
||||||
// Map NodeType strings to RouteNode types
|
// Map NodeType strings to RouteNode types
|
||||||
function mapNodeType(type: string): RouteNode['type'] {
|
function mapNodeType(type: string): RouteNode['type'] {
|
||||||
@@ -53,3 +53,29 @@ export function mapDiagramToRouteNodes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a flat RouteNode[] into FlowSegment[] (main route + error handlers).
|
||||||
|
* Returns the segments and an index map: indexMap[newFlatIndex] = originalIndex.
|
||||||
|
*/
|
||||||
|
export function toFlowSegments(nodes: RouteNode[]): { flows: FlowSegment[]; indexMap: number[] } {
|
||||||
|
const mainIndices: number[] = [];
|
||||||
|
const errorIndices: number[] = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (nodes[i].type === 'error-handler') {
|
||||||
|
errorIndices.push(i);
|
||||||
|
} else {
|
||||||
|
mainIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flows: FlowSegment[] = [
|
||||||
|
{ label: 'Main Route', nodes: mainIndices.map(i => nodes[i]) },
|
||||||
|
];
|
||||||
|
if (errorIndices.length > 0) {
|
||||||
|
flows.push({ label: 'Error Handler', nodes: errorIndices.map(i => nodes[i]), variant: 'error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexMap = [...mainIndices, ...errorIndices];
|
||||||
|
return { flows, indexMap };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user