feat: persist and display exchange properties from agent
Add support for exchange properties sent by the agent alongside headers. Properties flow through the same pipeline as headers: ClickHouse columns (input_properties, output_properties) on both executions and processor_executions tables, MergedExecution record, ChunkAccumulator extraction, DetailService snapshot, and REST API response. UI adds a Properties tab next to Headers in the process diagram detail panel, with the same input/output split table layout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
ui/src/api/schema.d.ts
vendored
2
ui/src/api/schema.d.ts
vendored
@@ -1945,6 +1945,8 @@ export interface components {
|
||||
outputBody: string;
|
||||
inputHeaders: string;
|
||||
outputHeaders: string;
|
||||
inputProperties: string;
|
||||
outputProperties: string;
|
||||
attributes: {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProcessorNode, ExecutionDetail, DetailTab } from './types';
|
||||
import { useProcessorSnapshotById } from '../../api/queries/executions';
|
||||
import { InfoTab } from './tabs/InfoTab';
|
||||
import { HeadersTab } from './tabs/HeadersTab';
|
||||
import { PropertiesTab } from './tabs/PropertiesTab';
|
||||
import { BodyTab } from './tabs/BodyTab';
|
||||
import { ErrorTab } from './tabs/ErrorTab';
|
||||
import { ConfigTab } from './tabs/ConfigTab';
|
||||
@@ -20,6 +21,7 @@ interface DetailPanelProps {
|
||||
const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'info', label: 'Info' },
|
||||
{ key: 'headers', label: 'Headers' },
|
||||
{ key: 'properties', label: 'Properties' },
|
||||
{ key: 'input', label: 'Input' },
|
||||
{ key: 'output', label: 'Output' },
|
||||
{ key: 'error', label: 'Error' },
|
||||
@@ -60,20 +62,24 @@ export function DetailPanel({
|
||||
let inputBody: string | undefined;
|
||||
let outputBody: string | undefined;
|
||||
let hasHeaders = false;
|
||||
let hasProperties = false;
|
||||
|
||||
if (selectedProcessor && snapshotQuery.data) {
|
||||
inputBody = snapshotQuery.data.inputBody;
|
||||
outputBody = snapshotQuery.data.outputBody;
|
||||
hasHeaders = !!(snapshotQuery.data.inputHeaders || snapshotQuery.data.outputHeaders);
|
||||
hasProperties = !!(snapshotQuery.data.inputProperties || snapshotQuery.data.outputProperties);
|
||||
} else if (selectedProcessor && snapshotQuery.isLoading) {
|
||||
// Still loading — keep tabs enabled
|
||||
hasHeaders = true;
|
||||
hasProperties = true;
|
||||
inputBody = undefined;
|
||||
outputBody = undefined;
|
||||
} else if (!selectedProcessor) {
|
||||
inputBody = executionDetail.inputBody;
|
||||
outputBody = executionDetail.outputBody;
|
||||
hasHeaders = !!(executionDetail.inputHeaders || executionDetail.outputHeaders);
|
||||
hasProperties = !!(executionDetail.inputProperties || executionDetail.outputProperties);
|
||||
}
|
||||
|
||||
const hasInput = !!inputBody;
|
||||
@@ -82,9 +88,10 @@ export function DetailPanel({
|
||||
// If active tab becomes disabled, fall back to info
|
||||
useEffect(() => {
|
||||
if (activeTab === 'headers' && !hasHeaders) setActiveTab('info');
|
||||
if (activeTab === 'properties' && !hasProperties) setActiveTab('info');
|
||||
if (activeTab === 'input' && !hasInput) setActiveTab('info');
|
||||
if (activeTab === 'output' && !hasOutput) setActiveTab('info');
|
||||
}, [hasHeaders, hasInput, hasOutput, activeTab]);
|
||||
}, [hasHeaders, hasProperties, hasInput, hasOutput, activeTab]);
|
||||
|
||||
return (
|
||||
<div className={styles.detailPanel}>
|
||||
@@ -99,6 +106,7 @@ export function DetailPanel({
|
||||
const isActive = activeTab === tab.key;
|
||||
const isDisabled = tab.key === 'config'
|
||||
|| (tab.key === 'headers' && !hasHeaders)
|
||||
|| (tab.key === 'properties' && !hasProperties)
|
||||
|| (tab.key === 'input' && !hasInput)
|
||||
|| (tab.key === 'output' && !hasOutput);
|
||||
const isError = tab.key === 'error' && hasError;
|
||||
@@ -138,6 +146,14 @@ export function DetailPanel({
|
||||
exchangeOutputHeaders={executionDetail.outputHeaders}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'properties' && (
|
||||
<PropertiesTab
|
||||
executionId={executionId}
|
||||
processorId={selectedProcessor?.processorId ?? null}
|
||||
exchangeInputProperties={executionDetail.inputProperties}
|
||||
exchangeOutputProperties={executionDetail.outputProperties}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'input' && (
|
||||
<BodyTab body={inputBody} label="Input" />
|
||||
)}
|
||||
|
||||
80
ui/src/components/ExecutionDiagram/tabs/PropertiesTab.tsx
Normal file
80
ui/src/components/ExecutionDiagram/tabs/PropertiesTab.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useProcessorSnapshotById } from '../../../api/queries/executions';
|
||||
import styles from '../ExecutionDiagram.module.css';
|
||||
|
||||
interface PropertiesTabProps {
|
||||
executionId: string;
|
||||
processorId: string | null;
|
||||
exchangeInputProperties?: string;
|
||||
exchangeOutputProperties?: string;
|
||||
}
|
||||
|
||||
function parseProperties(json: string | undefined): Record<string, string> {
|
||||
if (!json) return {};
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function PropertiesTable({ properties }: { properties: Record<string, string> }) {
|
||||
const entries = Object.entries(properties).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (entries.length === 0) {
|
||||
return <div className={styles.emptyState}>No properties</div>;
|
||||
}
|
||||
return (
|
||||
<table className={styles.headersTable}>
|
||||
<tbody>
|
||||
{entries.map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td className={styles.headerKey}>{k}</td>
|
||||
<td className={styles.headerVal}>{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertiesTab({
|
||||
executionId,
|
||||
processorId,
|
||||
exchangeInputProperties,
|
||||
exchangeOutputProperties,
|
||||
}: PropertiesTabProps) {
|
||||
const snapshotQuery = useProcessorSnapshotById(
|
||||
processorId ? executionId : null,
|
||||
processorId,
|
||||
);
|
||||
|
||||
let inputProperties: Record<string, string>;
|
||||
let outputProperties: Record<string, string>;
|
||||
|
||||
if (processorId && snapshotQuery.data) {
|
||||
inputProperties = parseProperties(snapshotQuery.data.inputProperties);
|
||||
outputProperties = parseProperties(snapshotQuery.data.outputProperties);
|
||||
} else if (!processorId) {
|
||||
inputProperties = parseProperties(exchangeInputProperties);
|
||||
outputProperties = parseProperties(exchangeOutputProperties);
|
||||
} else {
|
||||
inputProperties = {};
|
||||
outputProperties = {};
|
||||
}
|
||||
|
||||
if (processorId && snapshotQuery.isLoading) {
|
||||
return <div className={styles.emptyState}>Loading properties...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.headersSplit}>
|
||||
<div className={styles.headersColumn}>
|
||||
<div className={styles.headersColumnLabel}>Input Properties</div>
|
||||
<PropertiesTable properties={inputProperties} />
|
||||
</div>
|
||||
<div className={styles.headersColumn}>
|
||||
<div className={styles.headersColumnLabel}>Output Properties</div>
|
||||
<PropertiesTable properties={outputProperties} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,4 +27,4 @@ export interface IterationInfo {
|
||||
type: 'loop' | 'split' | 'multicast';
|
||||
}
|
||||
|
||||
export type DetailTab = 'info' | 'headers' | 'input' | 'output' | 'error' | 'config' | 'timeline' | 'log';
|
||||
export type DetailTab = 'info' | 'headers' | 'properties' | 'input' | 'output' | 'error' | 'config' | 'timeline' | 'log';
|
||||
|
||||
Reference in New Issue
Block a user