Files
new-api/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx
T
2026-04-28 15:57:01 +08:00

1002 lines
32 KiB
TypeScript
Vendored

import {
Copy,
Check,
Route,
Settings2,
AlertTriangle,
Headphones,
Monitor,
Cloud,
Globe,
ShieldCheck,
UserCog,
Info,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import type { UsageLog } from '../../data/schema'
import {
parseLogOther,
getParamOverrideActionLabel,
parseAuditLine,
decodeBillingExprB64,
getTieredBillingSummary,
hasAnyCacheTokens,
isViolationFeeLog,
getTimeColor,
} from '../../lib/format'
import {
getLogTypeConfig,
isPerCallBilling,
isTimingLogType,
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
function DetailRow(props: {
label: React.ReactNode
value: React.ReactNode
mono?: boolean
muted?: boolean
}) {
return (
<div className='flex items-start gap-3 text-sm'>
<span className='text-muted-foreground w-28 shrink-0 text-xs'>
{props.label}
</span>
<span
className={cn(
'min-w-0 text-xs break-words',
props.mono && 'font-mono',
props.muted && 'text-muted-foreground'
)}
>
{props.value}
</span>
</div>
)
}
function DetailSection(props: {
icon?: React.ReactNode
label: string
variant?: 'default' | 'danger'
children: React.ReactNode
}) {
const isDanger = props.variant === 'danger'
return (
<div className='space-y-1.5'>
<Label
className={cn(
'flex items-center gap-1.5 text-xs font-semibold',
isDanger && 'text-red-500'
)}
>
{props.icon}
{props.label}
</Label>
<div
className={cn(
'space-y-1.5 rounded-md border p-2.5',
isDanger
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20'
: 'bg-muted/30'
)}
>
{props.children}
</div>
</div>
)
}
function formatRatio(ratio: number | undefined): string {
if (ratio == null) return '-'
return ratio.toFixed(4)
}
function BillingBreakdown(props: {
log: UsageLog
other: LogOtherData
isAdmin: boolean
}) {
const { t } = useTranslation()
const { log, other, isAdmin } = props
const isPerCall = isPerCallBilling(other.model_price)
const isClaude = other.claude === true
const tieredSummary = getTieredBillingSummary(other)
const rows: Array<{ label: string; value: string }> = []
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const fmtPrice = (usd: number) => formatBillingCurrencyFromUSD(usd, priceOpts)
const baseInputUSD = other.model_ratio != null ? other.model_ratio * 2.0 : 0
if (tieredSummary) {
rows.push({
label: t('Billing Mode'),
value: t('Dynamic Pricing'),
})
if (tieredSummary.tier.label) {
rows.push({
label: t('Matched Tier'),
value: tieredSummary.tier.label,
})
}
for (const entry of tieredSummary.priceEntries) {
rows.push({
label: t(entry.shortLabel),
value: `${fmtPrice(entry.price)}/M`,
})
}
} else if (isPerCall) {
rows.push({ label: t('Billing Mode'), value: t('Per-call') })
if (other.model_price != null) {
rows.push({
label: t('Model Price'),
value: fmtPrice(other.model_price),
})
}
} else {
rows.push({ label: t('Billing Mode'), value: t('Per-token') })
if (other.model_ratio != null) {
rows.push({
label: t('Input'),
value: `${fmtPrice(baseInputUSD)}/M`,
})
}
if (other.completion_ratio != null && other.model_ratio != null) {
rows.push({
label: t('Output'),
value: `${fmtPrice(baseInputUSD * other.completion_ratio)}/M`,
})
}
}
const userGR = other.user_group_ratio
const isUserGR = userGR != null && Number.isFinite(userGR) && userGR !== -1
const effectiveGR = isUserGR ? userGR : other.group_ratio
if (effectiveGR != null && Number.isFinite(effectiveGR)) {
rows.push({
label: isUserGR ? t('User Exclusive Ratio') : t('Group Ratio'),
value: `${formatRatio(effectiveGR)}x`,
})
}
if (!tieredSummary && isClaude && hasAnyCacheTokens(other)) {
if (other.cache_ratio != null && other.cache_ratio !== 1) {
rows.push({
label: t('Cache Read'),
value: `${fmtPrice(baseInputUSD * other.cache_ratio)}/M`,
})
}
if (
other.cache_creation_ratio != null &&
other.cache_creation_ratio !== 1
) {
rows.push({
label: t('Cache Creation'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio)}/M`,
})
}
if (
other.cache_creation_ratio_5m != null &&
other.cache_creation_ratio_5m !== 0
) {
rows.push({
label: t('Cache Creation (5m)'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_5m)}/M`,
})
}
if (
other.cache_creation_ratio_1h != null &&
other.cache_creation_ratio_1h !== 0
) {
rows.push({
label: t('Cache Creation (1h)'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_1h)}/M`,
})
}
}
if (!tieredSummary) {
if (other.audio_ratio != null && other.audio_ratio !== 1) {
rows.push({
label: t('Audio input'),
value: `${fmtPrice(baseInputUSD * other.audio_ratio)}/M`,
})
}
if (
other.audio_completion_ratio != null &&
other.audio_completion_ratio !== 1
) {
rows.push({
label: t('Audio output'),
value: `${fmtPrice(baseInputUSD * other.audio_completion_ratio)}/M`,
})
}
if (other.image_ratio != null && other.image_ratio !== 1) {
rows.push({
label: t('Image input'),
value: `${fmtPrice(baseInputUSD * other.image_ratio)}/M`,
})
}
}
if (other.web_search && other.web_search_call_count) {
rows.push({
label: t('Web Search'),
value: `${other.web_search_call_count}x${other.web_search_price ? ` (${fmtPrice(other.web_search_price)})` : ''}`,
})
}
if (other.file_search && other.file_search_call_count) {
rows.push({
label: t('File Search'),
value: `${other.file_search_call_count}x${other.file_search_price ? ` (${fmtPrice(other.file_search_price)})` : ''}`,
})
}
if (other.image_generation_call && other.image_generation_call_price) {
rows.push({
label: t('Image Generation'),
value: fmtPrice(other.image_generation_call_price),
})
}
if (other.audio_input_seperate_price && other.audio_input_price) {
rows.push({
label: t('Audio Input Price'),
value: fmtPrice(other.audio_input_price),
})
}
if (isAdmin && other.admin_info) {
rows.push({
label: t('Billing Source'),
value: other.admin_info.local_count_tokens
? t('Local Billing')
: t('Upstream Response'),
})
}
rows.push({
label: t('Total Cost'),
value: formatLogQuota(log.quota),
})
if (rows.length === 0) return null
return (
<DetailSection label={t('Billing Details')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
}
function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
const { t } = useTranslation()
const { log, other } = props
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
const cacheRead = other.cache_tokens || 0
const cacheWrite = other.cache_creation_tokens || 0
const cacheWrite5m = other.cache_creation_tokens_5m || 0
const cacheWrite1h = other.cache_creation_tokens_1h || 0
const hasTokens = promptTokens > 0 || completionTokens > 0
if (!hasTokens) return null
const rows: Array<{ label: string; value: string }> = []
rows.push({ label: t('Input Tokens'), value: promptTokens.toLocaleString() })
rows.push({
label: t('Output Tokens'),
value: completionTokens.toLocaleString(),
})
if (cacheRead > 0) {
rows.push({
label: t('Cache Read'),
value: cacheRead.toLocaleString(),
})
}
if (cacheWrite > 0 && cacheWrite5m === 0 && cacheWrite1h === 0) {
rows.push({
label: t('Cache Write'),
value: cacheWrite.toLocaleString(),
})
}
if (cacheWrite5m > 0) {
rows.push({
label: t('Cache Write (5m)'),
value: cacheWrite5m.toLocaleString(),
})
}
if (cacheWrite1h > 0) {
rows.push({
label: t('Cache Write (1h)'),
value: cacheWrite1h.toLocaleString(),
})
}
if (other.image && other.image_output) {
rows.push({
label: t('Image Tokens'),
value: other.image_output.toLocaleString(),
})
}
return (
<DetailSection label={t('Token Breakdown')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
}
interface DetailsDialogProps {
log: UsageLog
isAdmin: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function DetailsDialog(props: DetailsDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const details = props.log.content ?? ''
const other = parseLogOther(props.log.other)
const typeConfig = getLogTypeConfig(props.log.type)
const isViolation = isViolationFeeLog(other)
const isRefund = props.log.type === 6
const isConsume = props.log.type === 2
const isTopup = props.log.type === 1
const isManage = props.log.type === 3
const isSubscription = other?.billing_source === 'subscription'
const isTieredBilling =
isConsume &&
!isViolation &&
other?.billing_mode === 'tiered_expr' &&
!!other?.expr_b64
const hasAudioTokens = other?.ws || other?.audio
const showTiming = isTimingLogType(props.log.type)
const showAdminIp =
!!props.log.ip && (showTiming || (props.isAdmin && isTopup))
const adminInfo = other?.admin_info
const topupAuditFields =
isTopup && props.isAdmin && adminInfo
? ([
adminInfo.payment_method && {
label: t('Order Payment Method'),
value: adminInfo.payment_method,
},
adminInfo.callback_payment_method && {
label: t('Callback Payment Method'),
value: adminInfo.callback_payment_method,
},
adminInfo.caller_ip && {
label: t('Callback Caller IP'),
value: adminInfo.caller_ip,
},
adminInfo.server_ip && {
label: t('Server IP'),
value: adminInfo.server_ip,
},
adminInfo.node_name && {
label: t('Node Name'),
value: adminInfo.node_name,
},
adminInfo.version && {
label: t('System Version'),
value: adminInfo.version,
},
].filter(Boolean) as Array<{ label: string; value: string }>)
: []
const showLegacyTopupWarning = isTopup && props.isAdmin && !adminInfo
const showTopupAuditSection =
isTopup &&
props.isAdmin &&
(topupAuditFields.length > 0 || showLegacyTopupWarning)
const manageOperator = (() => {
if (!isManage || !props.isAdmin || !adminInfo) return null
const username = adminInfo.admin_username
const id = adminInfo.admin_id
const hasUsername = username != null && String(username).trim() !== ''
const hasId = id != null && String(id).trim() !== ''
if (!hasUsername && !hasId) return null
if (hasUsername && hasId) return `${username} (ID: ${id})`
if (hasUsername) return String(username)
return `ID: ${id}`
})()
const conversionChain =
other && Array.isArray(other.request_conversion)
? other.request_conversion.filter(Boolean)
: []
const conversionLabel =
conversionChain.length <= 1
? t('Native format')
: conversionChain.join(' -> ')
const showConversion =
props.isAdmin &&
props.log.type !== 6 &&
(other?.request_path || conversionChain.length > 0)
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
className={cn(
'min-w-0',
isTieredBilling ? 'sm:max-w-4xl lg:max-w-5xl' : 'sm:max-w-lg'
)}
>
<DialogHeader>
<DialogTitle className='flex items-center gap-2 text-base'>
{t('Log Details')}
<StatusBadge
label={t(typeConfig.label)}
variant={typeConfig.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
/>
</DialogTitle>
<DialogDescription className='sr-only'>
{t('View the complete details for this log entry')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[70vh] min-w-0 pr-4'>
<div className='min-w-0 space-y-3 py-1'>
{/* Overview section - key identifiers */}
<div className='space-y-1.5'>
{props.log.request_id && (
<DetailRow
label={t('Request ID')}
value={props.log.request_id}
mono
/>
)}
{props.isAdmin && props.log.channel > 0 && (
<DetailRow
label={t('Channel')}
value={
<span>
{props.log.channel}
{props.log.channel_name && (
<span className='text-muted-foreground'>
{' '}
({props.log.channel_name})
</span>
)}
</span>
}
mono
/>
)}
{channelChain && props.isAdmin && (
<DetailRow label={t('Retry Chain')} value={channelChain} mono />
)}
{props.log.token_name && (
<DetailRow
label={t('Token')}
value={props.log.token_name}
mono
/>
)}
{(props.log.group || other?.group) && (
<DetailRow
label={t('Group')}
value={props.log.group || other?.group || ''}
mono
/>
)}
{showAdminIp && (
<DetailRow
label={t('IP Address')}
value={
<span className='flex items-center gap-1'>
<Globe
className='size-3 text-amber-500'
aria-hidden='true'
/>
{props.log.ip}
</span>
}
mono
/>
)}
{showTiming && props.log.use_time > 0 && (
<DetailRow
label={t('Response Time')}
value={
<span
className={cn(
'font-medium',
getTimeColor(props.log.use_time) === 'success'
? 'text-emerald-600'
: getTimeColor(props.log.use_time) === 'info'
? 'text-sky-600'
: 'text-amber-600'
)}
>
{formatUseTime(props.log.use_time)}
{props.log.is_stream &&
other?.frt != null &&
other.frt > 0 && (
<span className='text-muted-foreground font-normal'>
{' '}
(FRT: {formatUseTime(other.frt / 1000)})
</span>
)}
</span>
}
/>
)}
</div>
{/* Request conversion (admin only, not for refund) */}
{showConversion && (
<DetailSection label={t('Request Conversion')}>
<div className='relative'>
<Button
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-5 w-5 p-0'
onClick={() => copyToClipboard(conversionLabel)}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{copiedText === conversionLabel ? (
<Check className='size-3 text-green-600' />
) : (
<Copy className='size-3' />
)}
</Button>
<div className='space-y-1 pr-6'>
{other?.request_path && (
<DetailRow
label={t('Path')}
value={other.request_path}
mono
/>
)}
<div className='flex items-center gap-1.5 text-xs'>
<Route
className='text-muted-foreground size-3'
aria-hidden='true'
/>
<span className='break-words'>{conversionLabel}</span>
</div>
</div>
</div>
</DetailSection>
)}
{/* Reject reason (admin only) */}
{props.isAdmin && other?.reject_reason && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Reject Reason')}
variant='danger'
>
<p className='text-xs break-words'>{other.reject_reason}</p>
</DetailSection>
)}
{/* Violation fee info */}
{isViolation && other && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Violation Fee')}
variant='danger'
>
{other.violation_fee_code && (
<DetailRow
label={t('Violation Code')}
value={other.violation_fee_code}
mono
/>
)}
{other.violation_fee_marker && (
<DetailRow
label={t('Violation Marker')}
value={other.violation_fee_marker}
/>
)}
<DetailRow
label={t('Fee Amount')}
value={formatLogQuota(other.fee_quota ?? props.log.quota)}
mono
/>
</DetailSection>
)}
{/* Refund details (type=6) */}
{isRefund && other && (other.task_id || other.reason) && (
<DetailSection label={t('Refund Details')}>
{other.task_id && (
<DetailRow label={t('Task ID')} value={other.task_id} mono />
)}
{other.reason && (
<DetailRow label={t('Reason')} value={other.reason} />
)}
</DetailSection>
)}
{/* Top-up audit info (type=1, admin only) */}
{showTopupAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
label={t('Top-up Audit Info')}
>
{topupAuditFields.map((field, idx) => (
<DetailRow
key={idx}
label={field.label}
value={field.value}
mono
/>
))}
{showLegacyTopupWarning && (
<div className='flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400'>
<Info
className='mt-0.5 size-3.5 shrink-0'
aria-hidden='true'
/>
<span>
{t(
'This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.'
)}
</span>
</div>
)}
</DetailSection>
)}
{/* Manage operator (type=3, admin only) */}
{manageOperator && (
<DetailRow
label={
<span className='flex items-center gap-1.5'>
<UserCog
className='text-muted-foreground size-3.5'
aria-hidden='true'
/>
{t('Operator Admin')}
</span>
}
value={manageOperator}
mono
/>
)}
{/* Audio/WebSocket token breakdown */}
{hasAudioTokens && other && (
<DetailSection
icon={<Headphones className='size-3.5' aria-hidden='true' />}
label={t('Audio Tokens')}
>
{other.audio_input != null && other.audio_input > 0 && (
<DetailRow
label={t('Audio Input')}
value={formatTokens(other.audio_input)}
mono
/>
)}
{other.audio_output != null && other.audio_output > 0 && (
<DetailRow
label={t('Audio Output')}
value={formatTokens(other.audio_output)}
mono
/>
)}
{other.text_input != null && other.text_input > 0 && (
<DetailRow
label={t('Text Input')}
value={formatTokens(other.text_input)}
mono
/>
)}
{other.text_output != null && other.text_output > 0 && (
<DetailRow
label={t('Text Output')}
value={formatTokens(other.text_output)}
mono
/>
)}
</DetailSection>
)}
{/* Reasoning effort */}
{other?.reasoning_effort && (
<DetailRow
label={t('Reasoning Effort')}
value={
<StatusBadge
label={other.reasoning_effort}
variant={
other.reasoning_effort === 'high'
? 'orange'
: other.reasoning_effort === 'medium'
? 'yellow'
: 'green'
}
size='sm'
copyable={false}
/>
}
/>
)}
{/* System prompt override */}
{other?.is_system_prompt_overwritten && (
<DetailRow
label={t('System Prompt')}
value={
<StatusBadge
label={t('Overwritten')}
variant='orange'
size='sm'
copyable={false}
/>
}
/>
)}
{/* Model mapping */}
{other?.is_model_mapped && other?.upstream_model_name && (
<DetailSection label={t('Model Mapping')}>
<DetailRow
label={t('Request Model')}
value={props.log.model_name}
mono
/>
<DetailRow
label={t('Actual Model')}
value={other.upstream_model_name}
mono
/>
</DetailSection>
)}
{/* Token breakdown (for consume/error types with token data) */}
{isDisplayableType(props.log.type) && other && (
<TokenBreakdown log={props.log} other={other} />
)}
{/* Billing breakdown (consume type) */}
{isConsume && other && !isViolation && (
<BillingBreakdown
log={props.log}
other={other}
isAdmin={props.isAdmin}
/>
)}
{/* Tiered pricing breakdown (when billing_mode is tiered_expr) */}
{isTieredBilling && other?.expr_b64 && (
<div className='bg-muted/30 min-w-0 rounded-md border px-3'>
<DynamicPricingBreakdown
billingExpr={decodeBillingExprB64(other.expr_b64)}
matchedTierLabel={other.matched_tier}
hideCacheColumns={!hasAnyCacheTokens(other)}
/>
</div>
)}
{/* Admin billing mode indicator for non-consume */}
{props.isAdmin &&
!isConsume &&
props.log.type !== 6 &&
other?.admin_info && (
<DetailRow
label={t('Billing Source')}
value={
<span className='flex items-center gap-1'>
{other.admin_info.local_count_tokens ? (
<Monitor className='size-3 text-blue-500' />
) : (
<Cloud className='size-3 text-emerald-500' />
)}
<span className='text-xs'>
{other.admin_info.local_count_tokens
? t('Local Billing')
: t('Upstream Response')}
</span>
</span>
}
/>
)}
{/* Stream status details (admin only) */}
{props.isAdmin &&
other?.stream_status &&
other.stream_status.status !== 'ok' && (
<DetailSection label={t('Stream Status')}>
<DetailRow
label={t('Status')}
value={
<StatusBadge
label={other.stream_status.status || t('Error')}
variant='red'
size='sm'
copyable={false}
/>
}
/>
{other.stream_status.end_reason && (
<DetailRow
label={t('End Reason')}
value={other.stream_status.end_reason}
/>
)}
{(other.stream_status.error_count ?? 0) > 0 && (
<DetailRow
label={t('Soft Errors')}
value={String(other.stream_status.error_count)}
/>
)}
{other.stream_status.end_error && (
<DetailRow
label={t('End Error')}
value={other.stream_status.end_error}
/>
)}
{Array.isArray(other.stream_status.errors) &&
other.stream_status.errors.length > 0 && (
<pre className='bg-background/60 mt-1 max-h-32 overflow-y-auto rounded border p-2 font-mono text-[11px] leading-relaxed break-words whitespace-pre-wrap'>
{other.stream_status.errors.join('\n')}
</pre>
)}
</DetailSection>
)}
{/* Subscription billing details */}
{isSubscription && other && (
<DetailSection label={t('Subscription Billing')}>
{other.subscription_plan_id && (
<DetailRow
label={t('Plan')}
value={`#${other.subscription_plan_id} ${other.subscription_plan_title || ''}`.trim()}
/>
)}
{other.subscription_id && (
<DetailRow
label={t('Instance')}
value={`#${other.subscription_id}`}
mono
/>
)}
{other.subscription_pre_consumed != null && (
<DetailRow
label={t('Pre-consumed')}
value={formatLogQuota(other.subscription_pre_consumed)}
mono
/>
)}
{other.subscription_post_delta != null &&
other.subscription_post_delta !== 0 && (
<DetailRow
label={t('Post Delta')}
value={formatLogQuota(other.subscription_post_delta)}
mono
/>
)}
{other.subscription_consumed != null && (
<DetailRow
label={t('Final Consumed')}
value={formatLogQuota(other.subscription_consumed)}
mono
/>
)}
{other.subscription_remain != null && (
<DetailRow
label={t('Remaining')}
value={`${formatLogQuota(other.subscription_remain)}${other.subscription_total != null ? ` / ${formatLogQuota(other.subscription_total)}` : ''}`}
mono
/>
)}
</DetailSection>
)}
{/* Param override (admin only) */}
{props.isAdmin &&
other?.po &&
Array.isArray(other.po) &&
other.po.length > 0 && (
<DetailSection
icon={<Settings2 className='size-3.5' aria-hidden='true' />}
label={`${t('Param Override')} (${other.po.length})`}
>
{other.po.filter(Boolean).map((line, idx) => {
const parsed = parseAuditLine(line)
if (!parsed) return null
return (
<div
key={idx}
className='bg-background/60 flex items-start gap-2 rounded border p-2'
>
<StatusBadge
variant='neutral'
label={getParamOverrideActionLabel(parsed.action, t)}
className='shrink-0 font-medium'
copyable={false}
/>
<span className='min-w-0 font-mono text-[11px] leading-relaxed break-words'>
{parsed.content}
</span>
</div>
)
})}
</DetailSection>
)}
{/* Content */}
{details && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>{t('Content')}</Label>
<div className='bg-muted/30 relative rounded-md border p-2.5'>
<Button
variant='ghost'
size='sm'
className='absolute top-1.5 right-1.5 h-5 w-5 p-0'
onClick={() => copyToClipboard(details)}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{copiedText === details ? (
<Check className='size-3 text-green-600' />
) : (
<Copy className='size-3' />
)}
</Button>
<p className='pr-6 text-xs leading-relaxed break-words whitespace-pre-wrap'>
{details}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
function isDisplayableType(type: number): boolean {
return [0, 2, 5, 6].includes(type)
}