fix: OpenSearch status field mismatch, adopt RouteFlow flows prop
All checks were successful
CI / build (push) Successful in 56s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m43s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped

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:
hsiegeln
2026-03-25 18:34:58 +01:00
parent 2bbca8ae38
commit 20ee448f4e
8 changed files with 74 additions and 43 deletions

8
ui/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "ui",
"version": "0.0.0",
"dependencies": {
"@cameleer/design-system": "^0.0.0-snapshot.20260325.499c86b",
"@cameleer/design-system": "^0.1.10",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -276,9 +276,9 @@
}
},
"node_modules/@cameleer/design-system": {
"version": "0.0.0-snapshot.20260325.499c86b",
"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",
"integrity": "sha512-uiBdWYTT0wzIgL8QX21oHyb7xjeepnXvGAl/YHapd1o4u+GuXuB23kcyECurM/OTUN4dM7RGjGBp46Mbe8xcIQ==",
"version": "0.1.10",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.10/design-system-0.1.10.tgz",
"integrity": "sha512-1gQ8SM7AEgYv3UQNlC7qhcQ0HSIEFkYA34AV/hjDuFUQV3xrPI0g/p/YrJ5W4KIRb2YvnNqDPNgb6kLANufqRQ==",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -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"
},
"dependencies": {
"@cameleer/design-system": "^0.0.0-snapshot.20260325.499c86b",
"@cameleer/design-system": "^0.1.10",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",

View File

@@ -5,11 +5,11 @@ import { useRefreshInterval } from '../use-refresh-interval';
// ── Types ──────────────────────────────────────────────────────────────
export interface OpenSearchStatus {
connected: boolean;
reachable: boolean;
clusterHealth: string;
version: string | null;
numberOfNodes: number;
url: string;
nodeCount: number;
host: string;
}
export interface PipelineStats {

View File

@@ -23,10 +23,10 @@ export default function OpenSearchAdminPage() {
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
<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="Version" value={status?.version ?? '—'} />
<StatCard label="Nodes" value={status?.numberOfNodes ?? 0} />
<StatCard label="Nodes" value={status?.nodeCount ?? 0} />
</div>
{pipeline && (

View File

@@ -12,7 +12,7 @@ import {
Badge,
useGlobalFilters,
} from '@cameleer/design-system'
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
import type { Column, KpiItem } from '@cameleer/design-system'
import {
useSearchExecutions,
useExecutionStats,
@@ -21,7 +21,7 @@ import {
} from '../../api/queries/executions'
import { useDiagramLayout } from '../../api/queries/diagrams'
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'
// Row type extends ExecutionSummary with an `id` field for DataTable
@@ -355,9 +355,9 @@ export default function Dashboard() {
: (detail.children ?? [])
: []
const routeNodes: RouteNode[] = useMemo(() => {
const routeFlows = useMemo(() => {
if (diagram?.nodes) {
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes || [], procList)).flows
}
return []
}, [diagram, procList])
@@ -475,8 +475,8 @@ export default function Dashboard() {
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{routeNodes.length > 0 ? (
<RouteFlow nodes={routeNodes} />
{routeFlows.length > 0 ? (
<RouteFlow flows={routeFlows} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}

View File

@@ -8,7 +8,7 @@ import type { ProcessorStep, RouteNode, NodeBadge } from '@cameleer/design-syste
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation'
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 { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'
import styles from './ExchangeDetail.module.css'
@@ -128,32 +128,36 @@ export default function ExchangeDetail() {
const inputBody = snapshot?.inputBody ?? null
const outputBody = snapshot?.outputBody ?? null
// Build RouteFlow nodes from diagram + execution data
const routeNodes: RouteNode[] = useMemo(() => {
// Build RouteFlow nodes from diagram + execution data, split into flow segments
const { routeFlows, flowIndexMap } = useMemo(() => {
let nodes: RouteNode[]
if (diagram?.nodes) {
// Flatten processors to build diagramNodeId → processorId lookup
const flatProcs: Array<{ diagramNodeId?: string; processorId?: string }> = []
function flattenProcs(nodes: any[]) {
for (const n of nodes) { flatProcs.push(n); if (n.children) flattenProcs(n.children) }
function flattenProcs(list: any[]) {
for (const n of list) { flatProcs.push(n); if (n.children) flattenProcs(n.children) }
}
flattenProcs(procList)
const pidLookup = new Map(flatProcs
.filter(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,
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
return processors.map((p) => ({
name: p.name,
type: 'process' as RouteNode['type'],
durationMs: p.durationMs,
status: p.status,
badges: badgesFor(p.name),
}))
const { flows, indexMap } = toFlowSegments(nodes)
return { routeFlows: flows, flowIndexMap: indexMap }
}, [diagram, processors, procList, tracedMap])
// ProcessorId lookup: timeline index → processorId
@@ -388,13 +392,14 @@ export default function ExchangeDetail() {
<InfoCallout>No processor data available</InfoCallout>
)
) : (
routeNodes.length > 0 ? (
routeFlows.length > 0 ? (
<RouteFlow
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
flows={routeFlows}
onNodeClick={(_node, index) => setSelectedProcessorIndex(flowIndexMap[index] ?? index)}
selectedIndex={flowIndexMap.indexOf(activeIndex)}
getActions={(_node, index) => {
const pid = flowProcessorIds[index]
const origIdx = flowIndexMap[index] ?? index
const pid = flowProcessorIds[origIdx]
if (!pid || !detail?.applicationName) return []
return [{
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',

View File

@@ -21,7 +21,7 @@ import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
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';
// ── Row types ────────────────────────────────────────────────────────────────
@@ -321,9 +321,9 @@ export default function RouteDetail() {
}, [health]);
// Route flow from diagram
const diagramNodes = useMemo(() => {
const diagramFlows = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes, [])).flows;
}, [diagram]);
// Processor table rows
@@ -458,8 +458,8 @@ export default function RouteDetail() {
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
{diagramFlows.length > 0 ? (
<RouteFlow flows={diagramFlows} />
) : (
<div className={styles.emptyText}>
No diagram available for this route.
@@ -497,12 +497,12 @@ export default function RouteDetail() {
</div>
{/* Route Flow section */}
{diagramNodes.length > 0 && (
{diagramFlows.length > 0 && (
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={diagramNodes} />
<RouteFlow flows={diagramFlows} />
</div>
)}

View File

@@ -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
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 };
}