feat: persist and display exchange properties from agent
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m59s
CI / docker (push) Successful in 2m13s
CI / deploy (push) Successful in 58s
CI / deploy-feature (push) Has been skipped

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:
hsiegeln
2026-04-14 14:23:53 +02:00
parent 199d0259cd
commit 0827fd21e3
16 changed files with 180 additions and 36 deletions

View File

@@ -1945,6 +1945,8 @@ export interface components {
outputBody: string;
inputHeaders: string;
outputHeaders: string;
inputProperties: string;
outputProperties: string;
attributes: {
[key: string]: string;
};

View File

@@ -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" />
)}

View 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>
);
}

View File

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