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`; +}