Skip to content

Commit f21fe23

Browse files
authored
fix(formatting): consolidate duration formatting into shared utility (#3118)
* fix(formatting): consolidate duration formatting into shared utility * fix(formatting): preserve original precision and rounding behavior * fix(logs): add precision to logs list duration formatting * fix(formatting): use parseFloat to preserve fractional milliseconds * feat(ee): add enterprise modules (#3121) * fix(formatting): return null for missing values, strip trailing zeros
1 parent 9c3fd1f commit f21fe23

10 files changed

Lines changed: 66 additions & 89 deletions

File tree

apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import Link from 'next/link'
66
import { List, type RowComponentProps, useListRef } from 'react-window'
77
import { Badge, buttonVariants } from '@/components/emcn'
88
import { cn } from '@/lib/core/utils/cn'
9+
import { formatDuration } from '@/lib/core/utils/formatting'
910
import {
1011
DELETED_WORKFLOW_COLOR,
1112
DELETED_WORKFLOW_LABEL,
1213
formatDate,
13-
formatDuration,
1414
getDisplayStatus,
1515
LOG_COLUMNS,
1616
StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
113113

114114
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
115115
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
116-
{formatDuration(log.duration) || '—'}
116+
{formatDuration(log.duration, { precision: 2 }) || '—'}
117117
</Badge>
118118
</div>
119119
</div>

apps/sim/app/workspace/[workspaceId]/logs/utils.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
22
import { format } from 'date-fns'
33
import { Badge } from '@/components/emcn'
4+
import { formatDuration } from '@/lib/core/utils/formatting'
45
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
56
import { getBlock } from '@/blocks/registry'
67
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
362363
}
363364
}
364365

365-
/**
366-
* Format duration for display in logs UI
367-
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
368-
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
369-
* @param duration - Duration string (e.g., "500ms") or null
370-
* @returns Formatted duration string or null
371-
*/
372-
export function formatDuration(duration: string | null): string | null {
373-
if (!duration) return null
374-
375-
// Extract numeric value from duration string (e.g., "500ms" -> 500)
376-
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
377-
378-
if (!Number.isFinite(ms)) return duration
379-
380-
if (ms < 1000) {
381-
return `${ms}ms`
382-
}
383-
384-
// Convert to seconds with up to 2 decimal places
385-
const seconds = ms / 1000
386-
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
387-
}
388-
389366
/**
390367
* Format latency value for display in dashboard UI
391-
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
392-
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
393368
* @param ms - Latency in milliseconds (number)
394369
* @returns Formatted latency string
395370
*/
396371
export function formatLatency(ms: number): string {
397372
if (!Number.isFinite(ms) || ms <= 0) return '—'
398-
399-
if (ms < 1000) {
400-
return `${Math.round(ms)}ms`
401-
}
402-
403-
// Convert to seconds with up to 2 decimal places
404-
const seconds = ms / 1000
405-
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
373+
return formatDuration(ms, { precision: 2 }) ?? '—'
406374
}
407375

408376
export const formatDate = (dateString: string) => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { memo, useEffect, useMemo, useRef, useState } from 'react'
44
import clsx from 'clsx'
55
import { ChevronUp } from 'lucide-react'
6+
import { formatDuration } from '@/lib/core/utils/formatting'
67
import { CopilotMarkdownRenderer } from '../markdown-renderer'
78

89
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -241,15 +242,11 @@ export function ThinkingBlock({
241242
return () => window.clearInterval(intervalId)
242243
}, [isStreaming, isExpanded, userHasScrolledAway])
243244

244-
/** Formats duration in milliseconds to seconds (minimum 1s) */
245-
const formatDuration = (ms: number) => {
246-
const seconds = Math.max(1, Math.round(ms / 1000))
247-
return `${seconds}s`
248-
}
249-
250245
const hasContent = cleanContent.length > 0
251246
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
252-
const durationText = `${label} for ${formatDuration(duration)}`
247+
// Round to nearest second (minimum 1s) to match original behavior
248+
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
249+
const durationText = `${label} for ${formatDuration(roundedMs)}`
253250

254251
const getStreamingLabel = (lbl: string) => {
255252
if (lbl === 'Thought') return 'Thinking'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
hasInterrupt as hasInterruptFromConfig,
1616
isSpecialTool as isSpecialToolFromConfig,
1717
} from '@/lib/copilot/tools/client/ui-config'
18+
import { formatDuration } from '@/lib/core/utils/formatting'
1819
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
1920
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
2021
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
848849
(allParsed.options && Object.keys(allParsed.options).length > 0)
849850
)
850851

851-
const formatDuration = (ms: number) => {
852-
const seconds = Math.max(1, Math.round(ms / 1000))
853-
return `${seconds}s`
854-
}
855-
856852
const outerLabel = getSubagentCompletionLabel(toolCall.name)
857-
const durationText = `${outerLabel} for ${formatDuration(duration)}`
853+
// Round to nearest second (minimum 1s) to match original behavior
854+
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
855+
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
858856

859857
const renderCollapsibleContent = () => (
860858
<>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Tooltip,
2525
} from '@/components/emcn'
2626
import { getEnv, isTruthy } from '@/lib/core/config/env'
27+
import { formatDuration } from '@/lib/core/utils/formatting'
2728
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
2829
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
2930
import {
@@ -43,7 +44,6 @@ import {
4344
type EntryNode,
4445
type ExecutionGroup,
4546
flattenBlockEntriesOnly,
46-
formatDuration,
4747
getBlockColor,
4848
getBlockIcon,
4949
groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
128128
<StatusDisplay
129129
isRunning={isRunning}
130130
isCanceled={isCanceled}
131-
formattedDuration={formatDuration(entry.durationMs)}
131+
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
132132
/>
133133
</span>
134134
</div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
201201
<StatusDisplay
202202
isRunning={hasRunningChild}
203203
isCanceled={hasCanceledChild}
204-
formattedDuration={formatDuration(entry.durationMs)}
204+
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
205205
/>
206206
</span>
207207
</div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
314314
<StatusDisplay
315315
isRunning={hasRunningDescendant}
316316
isCanceled={hasCanceledDescendant}
317-
formattedDuration={formatDuration(entry.durationMs)}
317+
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
318318
/>
319319
</span>
320320
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
5353
return '#6b7280'
5454
}
5555

56-
/**
57-
* Formats duration from milliseconds to readable format
58-
*/
59-
export function formatDuration(ms?: number): string {
60-
if (ms === undefined || ms === null) return '-'
61-
if (ms < 1000) {
62-
return `${Math.round(ms)}ms`
63-
}
64-
return `${(ms / 1000).toFixed(2)}s`
65-
}
66-
6756
/**
6857
* Determines if a keyboard event originated from a text-editable element
6958
*/

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
Textarea,
3131
} from '@/components/emcn'
3232
import { cn } from '@/lib/core/utils/cn'
33+
import { formatDuration } from '@/lib/core/utils/formatting'
3334
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
3435
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
3536
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -575,7 +576,9 @@ export function TrainingModal() {
575576
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
576577
<span className='text-[var(--text-secondary)]'>
577578
{dataset.metadata?.duration
578-
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
579+
? formatDuration(dataset.metadata.duration, {
580+
precision: 1,
581+
})
579582
: 'N/A'}
580583
</span>
581584
</div>

apps/sim/background/workspace-notification-delivery.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
1919
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
2020
import { RateLimiter } from '@/lib/core/rate-limiter'
2121
import { decryptSecret } from '@/lib/core/security/encryption'
22+
import { formatDuration } from '@/lib/core/utils/formatting'
2223
import { getBaseUrl } from '@/lib/core/utils/urls'
2324
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
2425
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -227,12 +228,6 @@ async function deliverWebhook(
227228
}
228229
}
229230

230-
function formatDuration(ms: number): string {
231-
if (ms < 1000) return `${ms}ms`
232-
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
233-
return `${(ms / 60000).toFixed(1)}m`
234-
}
235-
236231
function formatCost(cost?: Record<string, unknown>): string {
237232
if (!cost?.total) return 'N/A'
238233
const total = cost.total as number
@@ -302,7 +297,7 @@ async function deliverEmail(
302297
workflowName: payload.data.workflowName || 'Unknown Workflow',
303298
status: payload.data.status,
304299
trigger: payload.data.trigger,
305-
duration: formatDuration(payload.data.totalDurationMs),
300+
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
306301
cost: formatCost(payload.data.cost),
307302
logUrl,
308303
alertReason,
@@ -315,7 +310,7 @@ async function deliverEmail(
315310
to: subscription.emailRecipients,
316311
subject,
317312
html,
318-
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
313+
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
319314
emailType: 'notifications',
320315
})
321316

@@ -373,7 +368,10 @@ async function deliverSlack(
373368
fields: [
374369
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
375370
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
376-
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
371+
{
372+
type: 'mrkdwn',
373+
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
374+
},
377375
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
378376
],
379377
},

apps/sim/components/ui/tool-call.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
77
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
88
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
99
import { cn } from '@/lib/core/utils/cn'
10+
import { formatDuration } from '@/lib/core/utils/formatting'
1011

1112
interface ToolCallProps {
1213
toolCall: ToolCallState
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
225226
const isError = toolCall.state === 'error'
226227
const isAborted = toolCall.state === 'aborted'
227228

228-
const formatDuration = (duration?: number) => {
229-
if (!duration) return ''
230-
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
231-
}
232-
233229
return (
234230
<div
235231
className={cn(
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
279275
)}
280276
style={{ fontSize: '0.625rem' }}
281277
>
282-
{formatDuration(toolCall.duration)}
278+
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
283279
</Badge>
284280
)}
285281
</div>

apps/sim/lib/core/utils/formatting.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
153153
}
154154

155155
/**
156-
* Format a duration in milliseconds to a human-readable format
157-
* @param durationMs - The duration in milliseconds
156+
* Format a duration to a human-readable format
157+
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
158158
* @param options - Optional formatting options
159-
* @param options.precision - Number of decimal places for seconds (default: 0)
160-
* @returns A formatted duration string
159+
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
160+
* @returns A formatted duration string, or null if input is null/undefined
161161
*/
162-
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
162+
export function formatDuration(
163+
duration: number | string | undefined | null,
164+
options?: { precision?: number }
165+
): string | null {
166+
if (duration === undefined || duration === null) {
167+
return null
168+
}
169+
170+
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
171+
let ms: number
172+
if (typeof duration === 'string') {
173+
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
174+
if (!Number.isFinite(ms)) {
175+
return duration
176+
}
177+
} else {
178+
ms = duration
179+
}
180+
163181
const precision = options?.precision ?? 0
164182

165-
if (durationMs < 1000) {
166-
return `${durationMs}ms`
183+
if (ms < 1) {
184+
// Sub-millisecond: show with 2 decimal places
185+
return `${ms.toFixed(2)}ms`
186+
}
187+
188+
if (ms < 1000) {
189+
// Milliseconds: round to integer
190+
return `${Math.round(ms)}ms`
167191
}
168192

169-
const seconds = durationMs / 1000
193+
const seconds = ms / 1000
170194
if (seconds < 60) {
171-
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
195+
if (precision > 0) {
196+
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
197+
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
198+
}
199+
return `${Math.floor(seconds)}s`
172200
}
173201

174202
const minutes = Math.floor(seconds / 60)

0 commit comments

Comments
 (0)