mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
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
This commit is contained in:
@@ -6,11 +6,11 @@ import Link from 'next/link'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
LOG_COLUMNS,
|
||||
StatusBadge,
|
||||
@@ -113,7 +113,7 @@ const LogRow = memo(
|
||||
|
||||
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
|
||||
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
|
||||
{formatDuration(log.duration) || '—'}
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display in logs UI
|
||||
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param duration - Duration string (e.g., "500ms") or null
|
||||
* @returns Formatted duration string or null
|
||||
*/
|
||||
export function formatDuration(duration: string | null): string | null {
|
||||
if (!duration) return null
|
||||
|
||||
// Extract numeric value from duration string (e.g., "500ms" -> 500)
|
||||
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
|
||||
|
||||
if (!Number.isFinite(ms)) return duration
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency value for display in dashboard UI
|
||||
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param ms - Latency in milliseconds (number)
|
||||
* @returns Formatted latency string
|
||||
*/
|
||||
export function formatLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
return formatDuration(ms, { precision: 2 }) ?? '—'
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
|
||||
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
|
||||
@@ -241,15 +242,11 @@ export function ThinkingBlock({
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/** Formats duration in milliseconds to seconds (minimum 1s) */
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = cleanContent.length > 0
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${label} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} from '@/lib/copilot/tools/client/ui-config'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||
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({
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
type EntryNode,
|
||||
type ExecutionGroup,
|
||||
flattenBlockEntriesOnly,
|
||||
formatDuration,
|
||||
getBlockColor,
|
||||
getBlockIcon,
|
||||
groupEntriesByExecution,
|
||||
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
|
||||
<StatusDisplay
|
||||
isRunning={isRunning}
|
||||
isCanceled={isCanceled}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningChild}
|
||||
isCanceled={hasCanceledChild}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningDescendant}
|
||||
isCanceled={hasCanceledDescendant}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to readable format
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms === undefined || ms === null) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
@@ -575,7 +576,9 @@ export function TrainingModal() {
|
||||
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
{dataset.metadata?.duration
|
||||
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
|
||||
? formatDuration(dataset.metadata.duration, {
|
||||
precision: 1,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -227,12 +228,6 @@ async function deliverWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
}
|
||||
|
||||
function formatCost(cost?: Record<string, unknown>): string {
|
||||
if (!cost?.total) return 'N/A'
|
||||
const total = cost.total as number
|
||||
@@ -302,7 +297,7 @@ async function deliverEmail(
|
||||
workflowName: payload.data.workflowName || 'Unknown Workflow',
|
||||
status: payload.data.status,
|
||||
trigger: payload.data.trigger,
|
||||
duration: formatDuration(payload.data.totalDurationMs),
|
||||
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
|
||||
cost: formatCost(payload.data.cost),
|
||||
logUrl,
|
||||
alertReason,
|
||||
@@ -315,7 +310,7 @@ async function deliverEmail(
|
||||
to: subscription.emailRecipients,
|
||||
subject,
|
||||
html,
|
||||
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}`,
|
||||
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}`,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
@@ -373,7 +368,10 @@ async function deliverSlack(
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
|
||||
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
|
||||
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
|
||||
},
|
||||
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
const isError = toolCall.state === 'error'
|
||||
const isAborted = toolCall.state === 'aborted'
|
||||
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return ''
|
||||
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
)}
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{formatDuration(toolCall.duration)}
|
||||
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a human-readable format
|
||||
* @param durationMs - The duration in milliseconds
|
||||
* Format a duration to a human-readable format
|
||||
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
|
||||
* @param options - Optional formatting options
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0)
|
||||
* @returns A formatted duration string
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
|
||||
* @returns A formatted duration string, or null if input is null/undefined
|
||||
*/
|
||||
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`
|
||||
export function formatDuration(
|
||||
duration: number | string | undefined | null,
|
||||
options?: { precision?: number }
|
||||
): string | null {
|
||||
if (duration === undefined || duration === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seconds = durationMs / 1000
|
||||
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
|
||||
let ms: number
|
||||
if (typeof duration === 'string') {
|
||||
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
|
||||
if (!Number.isFinite(ms)) {
|
||||
return duration
|
||||
}
|
||||
} else {
|
||||
ms = duration
|
||||
}
|
||||
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (ms < 1) {
|
||||
// Sub-millisecond: show with 2 decimal places
|
||||
return `${ms.toFixed(2)}ms`
|
||||
}
|
||||
|
||||
if (ms < 1000) {
|
||||
// Milliseconds: round to integer
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
|
||||
if (precision > 0) {
|
||||
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
|
||||
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
return `${Math.floor(seconds)}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
|
||||
Reference in New Issue
Block a user