diff --git a/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
index b538e5d3..853344e4 100644
--- a/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
+++ b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
@@ -1,6 +1,7 @@
import { Badge } from '@cameleer/design-system';
import type { ProcessorNode, ExecutionDetail } from '../types';
import { attributeBadgeColor } from '../../../utils/attribute-color';
+import { formatDurationShort } from '../../../utils/format-utils';
import styles from '../ExecutionDiagram.module.css';
interface InfoTabProps {
@@ -22,12 +23,6 @@ function formatTime(iso: string | undefined): string {
}
}
-function formatDuration(ms: number | undefined): string {
- if (ms === undefined || ms === null) return '-';
- if (ms < 1000) return `${ms}ms`;
- return `${(ms / 1000).toFixed(1)}s`;
-}
-
function statusClass(status: string): string {
const s = status?.toUpperCase();
if (s === 'COMPLETED') return styles.statusCompleted;
@@ -77,7 +72,7 @@ export function InfoTab({ processor, executionDetail }: InfoTabProps) {
-
+
@@ -107,7 +102,7 @@ export function InfoTab({ processor, executionDetail }: InfoTabProps) {
-
+
diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx
index 93c5ea87..3348ca8f 100644
--- a/ui/src/components/LayoutShell.tsx
+++ b/ui/src/components/LayoutShell.tsx
@@ -34,6 +34,7 @@ import type { ReactNode } from 'react';
import { ContentTabs } from './ContentTabs';
import { EnvironmentSelector } from './EnvironmentSelector';
import { useScope } from '../hooks/useScope';
+import { formatDuration } from '../utils/format-utils';
import {
buildAppTreeNodes,
buildAdminTreeNodes,
@@ -162,12 +163,6 @@ function healthToSearchColor(health: string): string {
}
}
-function formatDuration(ms: number): string {
- if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
- if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
- return `${ms}ms`;
-}
-
function statusToColor(status: string): string {
switch (status) {
case 'COMPLETED': return 'success';
diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx
index 6175cbc3..e49668c4 100644
--- a/ui/src/components/ProcessDiagram/DiagramNode.tsx
+++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx
@@ -3,6 +3,7 @@ import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'
import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType, type IconElement } from './node-colors';
+import { formatDurationShort } from '../../utils/format-utils';
const TOP_BAR_HEIGHT = 6;
const TEXT_LEFT = 32;
@@ -37,11 +38,6 @@ function heatmapBorderColor(pct: number): string {
return `hsl(${Math.round(hue)}, 60%, 50%)`;
}
-function formatDuration(ms: number): string {
- if (ms < 1000) return `${ms}ms`;
- return `${(ms / 1000).toFixed(1)}s`;
-}
-
export function DiagramNode({
node, isHovered, isSelected, config,
executionState, overlayActive, heatmapEntry,
@@ -273,7 +269,7 @@ export function DiagramNode({
fontSize={9}
fontWeight={500}
>
- {formatDuration(executionState.durationMs)}
+ {formatDurationShort(executionState.durationMs)}
)}
@@ -287,7 +283,7 @@ export function DiagramNode({
fontSize={9}
fontWeight={600}
>
- {formatDuration(heatmapEntry.avgDurationMs)}
+ {formatDurationShort(heatmapEntry.avgDurationMs)}
)}
diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx
index 5df825f7..238c30c0 100644
--- a/ui/src/pages/Admin/AppConfigDetailPage.tsx
+++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx
@@ -9,6 +9,7 @@ import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/quer
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
+import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
import styles from './AppConfigDetailPage.module.css';
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
@@ -130,18 +131,11 @@ export default function AppConfigDetailPage() {
}
function updateTracedProcessor(processorId: string, mode: string) {
- setTracedDraft((prev) => {
- if (mode === 'REMOVE') {
- const next = { ...prev };
- delete next[processorId];
- return next;
- }
- return { ...prev, [processorId]: mode };
- });
+ setTracedDraft((prev) => applyTracedProcessorUpdate(prev, processorId, mode));
}
function updateRouteRecording(routeId: string, recording: boolean) {
- setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
+ setRouteRecordingDraft((prev) => applyRouteRecordingUpdate(prev, routeId, recording));
}
function handleSave() {
diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx
index d1281c19..a77570c4 100644
--- a/ui/src/pages/AgentHealth/AgentHealth.tsx
+++ b/ui/src/pages/AgentHealth/AgentHealth.tsx
@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
-import { ExternalLink, RefreshCw, Pencil, UserPlus, UserMinus, Play, Square, Clock, Skull, HeartPulse, Route, Send, Activity } from 'lucide-react';
+import { ExternalLink, RefreshCw, Pencil } from 'lucide-react';
import {
StatCard, StatusDot, Badge, MonoText,
GroupCard, DataTable, EventFeed,
@@ -13,31 +13,11 @@ import { useApplicationLogs } from '../../api/queries/logs';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ConfigUpdateResponse } from '../../api/queries/commands';
import type { AgentInstance } from '../../api/types';
+import { timeAgo } from '../../utils/format-utils';
+import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils';
// ── Helpers ──────────────────────────────────────────────────────────────────
-function timeAgo(iso?: string): string {
- if (!iso) return '\u2014';
- const diff = Date.now() - new Date(iso).getTime();
- const secs = Math.floor(diff / 1000);
- if (secs < 60) return `${secs}s ago`;
- const mins = Math.floor(secs / 60);
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- return `${Math.floor(hours / 24)}d ago`;
-}
-
-function formatUptime(seconds?: number): string {
- if (!seconds) return '\u2014';
- const days = Math.floor(seconds / 86400);
- const hours = Math.floor((seconds % 86400) / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- if (days > 0) return `${days}d ${hours}h`;
- if (hours > 0) return `${hours}h ${mins}m`;
- return `${mins}m`;
-}
-
function formatErrorRate(rate?: number): string {
if (rate == null) return '\u2014';
return `${(rate * 100).toFixed(1)}%`;
@@ -104,16 +84,6 @@ const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'trace', label: 'Trace', color: 'var(--text-muted)' },
];
-function mapLogLevel(level: string): LogEntry['level'] {
- switch (level?.toUpperCase()) {
- case 'ERROR': return 'error';
- case 'WARN': case 'WARNING': return 'warn';
- case 'DEBUG': return 'debug';
- case 'TRACE': return 'trace';
- default: return 'info';
- }
-}
-
// ── AgentHealth page ─────────────────────────────────────────────────────────
export default function AgentHealth() {
@@ -195,35 +165,6 @@ export default function AgentHealth() {
// Map events to FeedEvent
const feedEvents: FeedEvent[] = useMemo(() => {
- const eventIcon = (type: string) => {
- switch (type) {
- case 'REGISTERED': return ;
- case 'DEREGISTERED': return ;
- case 'AGENT_STARTED': return ;
- case 'AGENT_STOPPED': return ;
- case 'WENT_STALE': return ;
- case 'WENT_DEAD': return ;
- case 'RECOVERED': return ;
- case 'ROUTE_STATE_CHANGED': return ;
- case 'COMMAND_DELIVERED':
- case 'COMMAND_ACKNOWLEDGED': return ;
- default: return ;
- }
- };
-
- const eventSeverity = (type: string): FeedEvent['severity'] => {
- switch (type) {
- case 'WENT_DEAD':
- case 'AGENT_STOPPED':
- case 'DEREGISTERED': return 'error';
- case 'WENT_STALE': return 'warning';
- case 'RECOVERED':
- case 'REGISTERED':
- case 'AGENT_STARTED': return 'success';
- default: return 'running';
- }
- };
-
const mapped = (events ?? []).map((e: { id: number; instanceId: string; eventType: string; detail: string; timestamp: string }) => ({
id: String(e.id),
severity: eventSeverity(e.eventType),
diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx
index 6e05e184..592639ec 100644
--- a/ui/src/pages/AgentInstance/AgentInstance.tsx
+++ b/ui/src/pages/AgentInstance/AgentInstance.tsx
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useParams, Link } from 'react-router';
-import { RefreshCw, ChevronRight, UserPlus, UserMinus, Play, Square, Clock, Skull, HeartPulse, Route, Send, Activity } from 'lucide-react';
+import { RefreshCw, ChevronRight } from 'lucide-react';
import {
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
@@ -12,6 +12,7 @@ import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
+import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils';
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'error', label: 'Error', color: 'var(--error)' },
@@ -21,16 +22,6 @@ const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'trace', label: 'Trace', color: 'var(--text-muted)' },
];
-function mapLogLevel(level: string): LogEntry['level'] {
- switch (level?.toUpperCase()) {
- case 'ERROR': return 'error';
- case 'WARN': case 'WARNING': return 'warn';
- case 'DEBUG': return 'debug';
- case 'TRACE': return 'trace';
- default: return 'info';
- }
-}
-
export default function AgentInstance() {
const { appId, instanceId } = useParams();
const { timeRange } = useGlobalFilters();
@@ -82,35 +73,6 @@ export default function AgentInstance() {
);
const feedEvents = useMemo(() => {
- const eventIcon = (type: string) => {
- switch (type) {
- case 'REGISTERED': return ;
- case 'DEREGISTERED': return ;
- case 'AGENT_STARTED': return ;
- case 'AGENT_STOPPED': return ;
- case 'WENT_STALE': return ;
- case 'WENT_DEAD': return ;
- case 'RECOVERED': return ;
- case 'ROUTE_STATE_CHANGED': return ;
- case 'COMMAND_DELIVERED':
- case 'COMMAND_ACKNOWLEDGED': return ;
- default: return ;
- }
- };
-
- const eventSeverity = (type: string): FeedEvent['severity'] => {
- switch (type) {
- case 'WENT_DEAD':
- case 'AGENT_STOPPED':
- case 'DEREGISTERED': return 'error';
- case 'WENT_STALE': return 'warning';
- case 'RECOVERED':
- case 'REGISTERED':
- case 'AGENT_STARTED': return 'success';
- default: return 'running';
- }
- };
-
const mapped = (events || [])
.filter((e: any) => !instanceId || e.instanceId === instanceId)
.map((e: any) => ({
@@ -495,13 +457,3 @@ export default function AgentInstance() {
);
}
-
-function formatUptime(seconds?: number): string {
- if (!seconds) return '\u2014';
- const days = Math.floor(seconds / 86400);
- const hours = Math.floor((seconds % 86400) / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- if (days > 0) return `${days}d ${hours}h`;
- if (hours > 0) return `${hours}h ${mins}m`;
- return `${mins}m`;
-}
diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx
index 69addbe9..46c33a25 100644
--- a/ui/src/pages/AppsTab/AppsTab.tsx
+++ b/ui/src/pages/AppsTab/AppsTab.tsx
@@ -36,6 +36,8 @@ import type { ApplicationConfig, TapDefinition } from '../../api/queries/command
import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { DeploymentProgress } from '../../components/DeploymentProgress';
+import { timeAgo } from '../../utils/format-utils';
+import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
import styles from './AppsTab.module.css';
function formatBytes(bytes: number): string {
@@ -44,16 +46,6 @@ function formatBytes(bytes: number): string {
return `${bytes} B`;
}
-function timeAgo(date: string): string {
- const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
- if (seconds < 60) return `${seconds}s ago`;
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- return `${Math.floor(hours / 24)}d ago`;
-}
-
const STATUS_COLORS: Record = {
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
DEGRADED: 'warning', STOPPING: 'auto',
@@ -801,14 +793,11 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
}
function updateTracedProcessor(processorId: string, mode: string) {
- setTracedDraft((prev) => {
- if (mode === 'REMOVE') { const next = { ...prev }; delete next[processorId]; return next; }
- return { ...prev, [processorId]: mode };
- });
+ setTracedDraft((prev) => applyTracedProcessorUpdate(prev, processorId, mode));
}
function updateRouteRecording(routeId: string, recording: boolean) {
- setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
+ setRouteRecordingDraft((prev) => applyRouteRecordingUpdate(prev, routeId, recording));
}
async function handleSave() {
diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx
index 74450306..9a472cbd 100644
--- a/ui/src/pages/Dashboard/Dashboard.tsx
+++ b/ui/src/pages/Dashboard/Dashboard.tsx
@@ -14,6 +14,7 @@ import {
} from '../../api/queries/executions'
import type { ExecutionSummary } from '../../api/types'
import { attributeBadgeColor } from '../../utils/attribute-color'
+import { formatDuration, statusLabel } from '../../utils/format-utils'
import styles from './Dashboard.module.css'
// Row type extends ExecutionSummary with an `id` field for DataTable
@@ -23,12 +24,6 @@ interface Row extends ExecutionSummary {
// ─── Helpers ─────────────────────────────────────────────────────────────────
-function formatDuration(ms: number): string {
- if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
- if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
- return `${ms}ms`
-}
-
function formatTimestamp(iso: string): string {
const date = new Date(iso)
const y = date.getFullYear()
@@ -49,15 +44,6 @@ function statusToVariant(status: string): 'success' | 'error' | 'running' | 'war
}
}
-function statusLabel(status: string): string {
- switch (status) {
- case 'COMPLETED': return 'OK'
- case 'FAILED': return 'ERR'
- case 'RUNNING': return 'RUN'
- default: return 'WARN'
- }
-}
-
function durationClass(ms: number, status: string): string {
if (status === 'FAILED') return styles.durBreach
if (ms < 100) return styles.durFast
diff --git a/ui/src/pages/Exchanges/ExchangeHeader.tsx b/ui/src/pages/Exchanges/ExchangeHeader.tsx
index c5b2166e..ba313389 100644
--- a/ui/src/pages/Exchanges/ExchangeHeader.tsx
+++ b/ui/src/pages/Exchanges/ExchangeHeader.tsx
@@ -8,6 +8,7 @@ import { useCatalog } from '../../api/queries/catalog';
import { useCanControl } from '../../auth/auth-store';
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
import { attributeBadgeColor } from '../../utils/attribute-color';
+import { formatDuration, statusLabel } from '../../utils/format-utils';
import { RouteControlBar } from './RouteControlBar';
import styles from './ExchangeHeader.module.css';
@@ -28,21 +29,6 @@ function statusVariant(s: string): StatusVariant {
}
}
-function statusLabel(s: string): string {
- switch (s) {
- case 'COMPLETED': return 'OK';
- case 'FAILED': return 'ERR';
- case 'RUNNING': return 'RUN';
- default: return s;
- }
-}
-
-function formatDuration(ms: number): string {
- if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
- if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
- return `${ms}ms`;
-}
-
export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }: ExchangeHeaderProps) {
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
diff --git a/ui/src/utils/agent-utils.ts b/ui/src/utils/agent-utils.ts
new file mode 100644
index 00000000..68b6e9bc
--- /dev/null
+++ b/ui/src/utils/agent-utils.ts
@@ -0,0 +1,53 @@
+import type { FeedEvent } from '@cameleer/design-system';
+import type { LogEntry } from '@cameleer/design-system';
+import { UserPlus, UserMinus, Play, Square, Clock, Skull, HeartPulse, Route, Send, Activity } from 'lucide-react';
+import { createElement } from 'react';
+
+export function formatUptime(seconds?: number): string {
+ if (!seconds) return '\u2014';
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ if (days > 0) return `${days}d ${hours}h`;
+ if (hours > 0) return `${hours}h ${mins}m`;
+ return `${mins}m`;
+}
+
+export function mapLogLevel(level: string): LogEntry['level'] {
+ switch (level?.toUpperCase()) {
+ case 'ERROR': return 'error';
+ case 'WARN': case 'WARNING': return 'warn';
+ case 'DEBUG': return 'debug';
+ case 'TRACE': return 'trace';
+ default: return 'info';
+ }
+}
+
+export function eventSeverity(type: string): FeedEvent['severity'] {
+ switch (type) {
+ case 'WENT_DEAD':
+ case 'AGENT_STOPPED':
+ case 'DEREGISTERED': return 'error';
+ case 'WENT_STALE': return 'warning';
+ case 'RECOVERED':
+ case 'REGISTERED':
+ case 'AGENT_STARTED': return 'success';
+ default: return 'running';
+ }
+}
+
+export function eventIcon(type: string) {
+ switch (type) {
+ case 'REGISTERED': return createElement(UserPlus, { size: 14 });
+ case 'DEREGISTERED': return createElement(UserMinus, { size: 14 });
+ case 'AGENT_STARTED': return createElement(Play, { size: 14 });
+ case 'AGENT_STOPPED': return createElement(Square, { size: 14 });
+ case 'WENT_STALE': return createElement(Clock, { size: 14 });
+ case 'WENT_DEAD': return createElement(Skull, { size: 14 });
+ case 'RECOVERED': return createElement(HeartPulse, { size: 14 });
+ case 'ROUTE_STATE_CHANGED': return createElement(Route, { size: 14 });
+ case 'COMMAND_DELIVERED':
+ case 'COMMAND_ACKNOWLEDGED': return createElement(Send, { size: 14 });
+ default: return createElement(Activity, { size: 14 });
+ }
+}
diff --git a/ui/src/utils/config-draft-utils.ts b/ui/src/utils/config-draft-utils.ts
new file mode 100644
index 00000000..3adb62ee
--- /dev/null
+++ b/ui/src/utils/config-draft-utils.ts
@@ -0,0 +1,20 @@
+export function applyTracedProcessorUpdate(
+ prev: Record,
+ processorId: string,
+ mode: string,
+): Record {
+ if (mode === 'REMOVE') {
+ const next = { ...prev };
+ delete next[processorId];
+ return next;
+ }
+ return { ...prev, [processorId]: mode };
+}
+
+export function applyRouteRecordingUpdate(
+ prev: Record,
+ routeId: string,
+ recording: boolean,
+): Record {
+ return { ...prev, [routeId]: recording };
+}
diff --git a/ui/src/utils/format-utils.ts b/ui/src/utils/format-utils.ts
new file mode 100644
index 00000000..d8125e40
--- /dev/null
+++ b/ui/src/utils/format-utils.ts
@@ -0,0 +1,32 @@
+export function formatDuration(ms: number): string {
+ if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
+ return `${ms}ms`;
+}
+
+export function formatDurationShort(ms: number | undefined): string {
+ if (ms == null) return '-';
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+export function statusLabel(s: string): string {
+ switch (s) {
+ case 'COMPLETED': return 'OK';
+ case 'FAILED': return 'ERR';
+ case 'RUNNING': return 'RUN';
+ default: return s;
+ }
+}
+
+export function timeAgo(iso?: string): string {
+ if (!iso) return '\u2014';
+ const diff = Date.now() - new Date(iso).getTime();
+ const secs = Math.floor(diff / 1000);
+ if (secs < 60) return `${secs}s ago`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}