Commit 90bce60b authored by yangjianbo's avatar yangjianbo
Browse files

feat: merge dev

parent a458e684
...@@ -549,7 +549,7 @@ ...@@ -549,7 +549,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" /> <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" /> <input v-model.number="form.concurrency" type="number" min="1" class="input" />
...@@ -564,6 +564,11 @@ ...@@ -564,6 +564,11 @@
data-tour="account-form-priority" data-tour="account-form-priority"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
...@@ -807,6 +812,7 @@ const form = reactive({ ...@@ -807,6 +812,7 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
rate_multiplier: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[], group_ids: [] as number[],
expires_at: null as number | null expires_at: null as number | null
...@@ -834,6 +840,7 @@ watch( ...@@ -834,6 +840,7 @@ watch(
form.proxy_id = newAccount.proxy_id form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency form.concurrency = newAccount.concurrency
form.priority = newAccount.priority form.priority = newAccount.priority
form.rate_multiplier = newAccount.rate_multiplier ?? 1
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null form.expires_at = newAccount.expires_at ?? null
......
...@@ -15,7 +15,13 @@ ...@@ -15,7 +15,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }} {{ formatTokens }}
</span> </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div> </div>
</div> </div>
...@@ -149,8 +155,13 @@ const formatTokens = computed(() => { ...@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return t.toString() return t.toString()
}) })
const formatCost = computed(() => { const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00' if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2) return props.windowStats.cost.toFixed(2)
}) })
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script> </script>
...@@ -61,11 +61,12 @@ ...@@ -61,11 +61,12 @@
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }} {{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500" <span class="text-gray-400 dark:text-gray-500">
>({{ t('admin.accounts.stats.standardCost') }}: ${{ ({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost) formatCost(stats.summary.total_standard_cost)
}})</span }})
> </span>
</p> </p>
</div> </div>
...@@ -108,12 +109,15 @@ ...@@ -108,12 +109,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }} ${{ formatCost(stats.summary.avg_daily_cost) }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ {{
t('admin.accounts.stats.basedOnActualDays', { t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used days: stats.summary.actual_days_used
}) })
}} }}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p> </p>
</div> </div>
...@@ -164,13 +168,17 @@ ...@@ -164,13 +168,17 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -210,13 +218,17 @@ ...@@ -210,13 +218,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400" <span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -260,13 +272,17 @@ ...@@ -260,13 +272,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -485,14 +501,24 @@ const trendChartData = computed(() => { ...@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label), labels: stats.value.history.map((h) => h.label),
datasets: [ datasets: [
{ {
label: t('admin.accounts.stats.cost') + ' (USD)', label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.cost), data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6', borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
yAxisID: 'y' yAxisID: 'y'
}, },
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{ {
label: t('admin.accounts.stats.requests'), label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests), data: stats.value.history.map((h) => h.requests),
...@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({ ...@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
}, },
title: { title: {
display: true, display: true,
text: t('admin.accounts.stats.cost') + ' (USD)', text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6', color: '#3b82f6',
font: { font: {
size: 11 size: 11
......
...@@ -27,9 +27,18 @@ ...@@ -27,9 +27,18 @@
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p> <p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p> <p class="text-xl font-bold text-green-600">
<p class="text-xs text-gray-400"> ${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span> </p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p> </p>
</div> </div>
</div> </div>
......
...@@ -81,18 +81,23 @@ ...@@ -81,18 +81,23 @@
</template> </template>
<template #cell-cost="{ row }"> <template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm"> <div class="text-sm">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span> <div class="flex items-center gap-1.5">
<!-- Cost Detail Tooltip --> <span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<div <!-- Cost Detail Tooltip -->
class="group relative" <div
@mouseenter="showTooltip($event, row)" class="group relative"
@mouseleave="hideTooltip" @mouseenter="showTooltip($event, row)"
> @mouseleave="hideTooltip"
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"> >
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" /> <div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div> </div>
</div> </div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
</div>
</div> </div>
</template> </template>
...@@ -202,14 +207,24 @@ ...@@ -202,14 +207,24 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span> <span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div> </div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span> <span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.billed') }}</span> <span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div> </div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div> <div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div> </div>
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
<label class="input-label">{{ t('admin.users.username') }}</label> <label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" /> <input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label> <label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" /> <input v-model.number="form.balance" type="number" step="any" class="input" />
......
<template> <template>
<div class="md:hidden space-y-3">
<template v-if="loading">
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
<div class="space-y-3">
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</div>
</template>
<template v-else-if="!data || data.length === 0">
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
<slot name="empty">
<div class="flex flex-col items-center">
<Icon
name="inbox"
size="xl"
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
/>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ t('empty.noData') }}
</p>
</div>
</slot>
</div>
</template>
<template v-else>
<div
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="space-y-3">
<div
v-for="column in columns.filter(c => c.key !== 'actions')"
:key="column.key"
class="flex items-start justify-between gap-4"
>
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
{{ column.label }}
</span>
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
</div>
</div>
</div>
</template>
</div>
<div <div
ref="tableWrapperRef" ref="tableWrapperRef"
class="table-wrapper" class="table-wrapper hidden md:block"
:class="{ :class="{
'actions-expanded': actionsExpanded, 'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable 'is-scrollable': isScrollable
...@@ -22,29 +83,36 @@ ...@@ -22,29 +83,36 @@
]" ]"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
<div class="flex items-center space-x-1"> <slot
<span>{{ column.label }}</span> :name="`header-${column.key}`"
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> :column="column"
<svg :sort-key="sortKey"
v-if="sortKey === column.key" :sort-order="sortOrder"
class="h-4 w-4" >
:class="{ 'rotate-180 transform': sortOrder === 'desc' }" <div class="flex items-center space-x-1">
fill="currentColor" <span>{{ column.label }}</span>
viewBox="0 0 20 20" <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
> <svg
<path v-if="sortKey === column.key"
fill-rule="evenodd" class="h-4 w-4"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" :class="{ 'rotate-180 transform': sortOrder === 'desc' }"
clip-rule="evenodd" fill="currentColor"
/> viewBox="0 0 20 20"
</svg> >
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <path
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
/> clip-rule="evenodd"
</svg> />
</span> </svg>
</div> <svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
</slot>
</th> </th>
</tr> </tr>
</thead> </thead>
...@@ -277,7 +345,10 @@ const sortedData = computed(() => { ...@@ -277,7 +345,10 @@ const sortedData = computed(() => {
}) })
}) })
// 检查第一列是否为勾选列 const hasActionsColumn = computed(() => {
return props.columns.some(column => column.key === 'actions')
})
const hasSelectColumn = computed(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'
}) })
......
...@@ -129,6 +129,8 @@ export default { ...@@ -129,6 +129,8 @@ export default {
all: 'All', all: 'All',
none: 'None', none: 'None',
noData: 'No data', noData: 'No data',
expand: 'Expand',
collapse: 'Collapse',
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
critical: 'Critical', critical: 'Critical',
...@@ -150,12 +152,13 @@ export default { ...@@ -150,12 +152,13 @@ export default {
invalidEmail: 'Please enter a valid email address', invalidEmail: 'Please enter a valid email address',
optional: 'optional', optional: 'optional',
selectOption: 'Select an option', selectOption: 'Select an option',
searchPlaceholder: 'Search...', searchPlaceholder: 'Search...',
noOptionsFound: 'No options found', noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available', noGroupsAvailable: 'No groups available',
unknownError: 'Unknown error occurred', unknownError: 'Unknown error occurred',
saving: 'Saving...', saving: 'Saving...',
selectedCount: '({count} selected)', refresh: 'Refresh', selectedCount: '({count} selected)',
refresh: 'Refresh',
settings: 'Settings', settings: 'Settings',
notAvailable: 'N/A', notAvailable: 'N/A',
now: 'Now', now: 'Now',
...@@ -429,6 +432,9 @@ export default { ...@@ -429,6 +432,9 @@ export default {
totalCost: 'Total Cost', totalCost: 'Total Cost',
standardCost: 'Standard', standardCost: 'Standard',
actualCost: 'Actual', actualCost: 'Actual',
userBilled: 'User billed',
accountBilled: 'Account billed',
accountMultiplier: 'Account rate',
avgDuration: 'Avg Duration', avgDuration: 'Avg Duration',
inSelectedRange: 'in selected range', inSelectedRange: 'in selected range',
perRequest: 'per request', perRequest: 'per request',
...@@ -1059,6 +1065,7 @@ export default { ...@@ -1059,6 +1065,7 @@ export default {
concurrencyStatus: 'Concurrency', concurrencyStatus: 'Concurrency',
notes: 'Notes', notes: 'Notes',
priority: 'Priority', priority: 'Priority',
billingRateMultiplier: 'Billing Rate',
weight: 'Weight', weight: 'Weight',
status: 'Status', status: 'Status',
schedulable: 'Schedulable', schedulable: 'Schedulable',
...@@ -1226,6 +1233,8 @@ export default { ...@@ -1226,6 +1233,8 @@ export default {
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Lower value accounts are used first', priorityHint: 'Lower value accounts are used first',
billingRateMultiplier: 'Billing Rate Multiplier',
billingRateMultiplierHint: '>=0, 0 means free. Affects account billing only',
expiresAt: 'Expires At', expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration', expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority', higherPriorityFirst: 'Lower value means higher priority',
...@@ -1627,11 +1636,29 @@ export default { ...@@ -1627,11 +1636,29 @@ export default {
address: 'Address', address: 'Address',
status: 'Status', status: 'Status',
accounts: 'Accounts', accounts: 'Accounts',
latency: 'Latency',
actions: 'Actions' actions: 'Actions'
}, },
testConnection: 'Test Connection', testConnection: 'Test Connection',
batchTest: 'Test All Proxies', batchTest: 'Test All Proxies',
testFailed: 'Failed', testFailed: 'Failed',
latencyFailed: 'Connection failed',
batchTestEmpty: 'No proxies available for testing',
batchTestDone: 'Batch test completed for {count} proxies',
batchTestFailed: 'Batch test failed',
batchDeleteAction: 'Delete',
batchDelete: 'Batch delete',
batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.',
batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}',
batchDeleteSkipped: 'Skipped {skipped} proxies',
batchDeleteFailed: 'Batch delete failed',
deleteBlockedInUse: 'This proxy is in use and cannot be deleted',
accountsTitle: 'Accounts using this IP',
accountsEmpty: 'No accounts are using this proxy',
accountsFailed: 'Failed to load accounts list',
accountName: 'Account',
accountPlatform: 'Platform',
accountNotes: 'Notes',
name: 'Name', name: 'Name',
protocol: 'Protocol', protocol: 'Protocol',
host: 'Host', host: 'Host',
...@@ -1858,10 +1885,8 @@ export default { ...@@ -1858,10 +1885,8 @@ export default {
noSystemMetrics: 'No system metrics collected yet.', noSystemMetrics: 'No system metrics collected yet.',
collectedAt: 'Collected at:', collectedAt: 'Collected at:',
window: 'window', window: 'window',
cpu: 'CPU',
memory: 'Memory', memory: 'Memory',
db: 'DB', db: 'DB',
redis: 'Redis',
goroutines: 'Goroutines', goroutines: 'Goroutines',
jobs: 'Jobs', jobs: 'Jobs',
jobsHelp: 'Click “Details” to view job heartbeats and recent errors', jobsHelp: 'Click “Details” to view job heartbeats and recent errors',
...@@ -1887,7 +1912,7 @@ export default { ...@@ -1887,7 +1912,7 @@ export default {
totalRequests: 'Total Requests', totalRequests: 'Total Requests',
avgQps: 'Avg QPS', avgQps: 'Avg QPS',
avgTps: 'Avg TPS', avgTps: 'Avg TPS',
avgLatency: 'Avg Latency', avgLatency: 'Avg Request Duration',
avgTtft: 'Avg TTFT', avgTtft: 'Avg TTFT',
exceptions: 'Exceptions', exceptions: 'Exceptions',
requestErrors: 'Request Errors', requestErrors: 'Request Errors',
...@@ -1899,7 +1924,7 @@ export default { ...@@ -1899,7 +1924,7 @@ export default {
errors: 'Errors', errors: 'Errors',
errorRate: 'error_rate:', errorRate: 'error_rate:',
upstreamRate: 'upstream_rate:', upstreamRate: 'upstream_rate:',
latencyDuration: 'Latency (duration_ms)', latencyDuration: 'Request Duration (ms)',
ttftLabel: 'TTFT (first_token_ms)', ttftLabel: 'TTFT (first_token_ms)',
p50: 'p50:', p50: 'p50:',
p90: 'p90:', p90: 'p90:',
...@@ -1907,7 +1932,6 @@ export default { ...@@ -1907,7 +1932,6 @@ export default {
p99: 'p99:', p99: 'p99:',
avg: 'avg:', avg: 'avg:',
max: 'max:', max: 'max:',
qps: 'QPS',
requests: 'Requests', requests: 'Requests',
requestsTitle: 'Requests', requestsTitle: 'Requests',
upstream: 'Upstream', upstream: 'Upstream',
...@@ -1919,7 +1943,7 @@ export default { ...@@ -1919,7 +1943,7 @@ export default {
failedToLoadData: 'Failed to load ops data.', failedToLoadData: 'Failed to load ops data.',
failedToLoadOverview: 'Failed to load overview', failedToLoadOverview: 'Failed to load overview',
failedToLoadThroughputTrend: 'Failed to load throughput trend', failedToLoadThroughputTrend: 'Failed to load throughput trend',
failedToLoadLatencyHistogram: 'Failed to load latency histogram', failedToLoadLatencyHistogram: 'Failed to load request duration histogram',
failedToLoadErrorTrend: 'Failed to load error trend', failedToLoadErrorTrend: 'Failed to load error trend',
failedToLoadErrorDistribution: 'Failed to load error distribution', failedToLoadErrorDistribution: 'Failed to load error distribution',
failedToLoadErrorDetail: 'Failed to load error detail', failedToLoadErrorDetail: 'Failed to load error detail',
...@@ -1927,7 +1951,7 @@ export default { ...@@ -1927,7 +1951,7 @@ export default {
tpsK: 'TPS (K)', tpsK: 'TPS (K)',
top: 'Top:', top: 'Top:',
throughputTrend: 'Throughput Trend', throughputTrend: 'Throughput Trend',
latencyHistogram: 'Latency Histogram', latencyHistogram: 'Request Duration Histogram',
errorTrend: 'Error Trend', errorTrend: 'Error Trend',
errorDistribution: 'Error Distribution', errorDistribution: 'Error Distribution',
// Health Score & Diagnosis // Health Score & Diagnosis
...@@ -1942,7 +1966,9 @@ export default { ...@@ -1942,7 +1966,9 @@ export default {
'30m': 'Last 30 minutes', '30m': 'Last 30 minutes',
'1h': 'Last 1 hour', '1h': 'Last 1 hour',
'6h': 'Last 6 hours', '6h': 'Last 6 hours',
'24h': 'Last 24 hours' '24h': 'Last 24 hours',
'7d': 'Last 7 days',
'30d': 'Last 30 days'
}, },
fullscreen: { fullscreen: {
enter: 'Enter Fullscreen' enter: 'Enter Fullscreen'
...@@ -1971,14 +1997,7 @@ export default { ...@@ -1971,14 +1997,7 @@ export default {
memoryHigh: 'Memory usage elevated ({usage}%)', memoryHigh: 'Memory usage elevated ({usage}%)',
memoryHighImpact: 'Memory pressure is high, needs attention', memoryHighImpact: 'Memory pressure is high, needs attention',
memoryHighAction: 'Monitor memory trends, check for memory leaks', memoryHighAction: 'Monitor memory trends, check for memory leaks',
// Latency diagnostics ttftHigh: 'Time to first token elevated ({ttft}ms)',
latencyCritical: 'Response latency critically high ({latency}ms)',
latencyCriticalImpact: 'User experience extremely poor, many requests timing out',
latencyCriticalAction: 'Check slow queries, database indexes, network latency, and upstream services',
latencyHigh: 'Response latency elevated ({latency}ms)',
latencyHighImpact: 'User experience degraded, needs optimization',
latencyHighAction: 'Analyze slow request logs, optimize database queries and business logic',
ttftHigh: 'Time to first byte elevated ({ttft}ms)',
ttftHighImpact: 'User perceived latency increased', ttftHighImpact: 'User perceived latency increased',
ttftHighAction: 'Optimize request processing flow, reduce pre-processing time', ttftHighAction: 'Optimize request processing flow, reduce pre-processing time',
// Error rate diagnostics // Error rate diagnostics
...@@ -2014,27 +2033,106 @@ export default { ...@@ -2014,27 +2033,106 @@ export default {
// Error Log // Error Log
errorLog: { errorLog: {
timeId: 'Time / ID', timeId: 'Time / ID',
commonErrors: {
contextDeadlineExceeded: 'context deadline exceeded',
connectionRefused: 'connection refused',
rateLimit: 'rate limit'
},
time: 'Time',
type: 'Type',
context: 'Context', context: 'Context',
platform: 'Platform',
model: 'Model',
group: 'Group',
user: 'User',
userId: 'User ID',
account: 'Account',
accountId: 'Account ID',
status: 'Status', status: 'Status',
message: 'Message', message: 'Message',
latency: 'Latency', latency: 'Request Duration',
action: 'Action', action: 'Action',
noErrors: 'No errors in this window.', noErrors: 'No errors in this window.',
grp: 'GRP:', grp: 'GRP:',
acc: 'ACC:', acc: 'ACC:',
details: 'Details', details: 'Details',
phase: 'Phase' phase: 'Phase',
id: 'ID:',
typeUpstream: 'Upstream',
typeRequest: 'Request',
typeAuth: 'Auth',
typeRouting: 'Routing',
typeInternal: 'Internal'
}, },
// Error Details Modal // Error Details Modal
errorDetails: { errorDetails: {
upstreamErrors: 'Upstream Errors', upstreamErrors: 'Upstream Errors',
requestErrors: 'Request Errors', requestErrors: 'Request Errors',
unresolved: 'Unresolved',
resolved: 'Resolved',
viewErrors: 'Errors',
viewExcluded: 'Excluded',
statusCodeOther: 'Other',
owner: {
provider: 'Provider',
client: 'Client',
platform: 'Platform'
},
phase: {
request: 'Request',
auth: 'Auth',
routing: 'Routing',
upstream: 'Upstream',
network: 'Network',
internal: 'Internal'
},
total: 'Total:', total: 'Total:',
searchPlaceholder: 'Search request_id / client_request_id / message', searchPlaceholder: 'Search request_id / client_request_id / message',
accountIdPlaceholder: 'account_id'
}, },
// Error Detail Modal // Error Detail Modal
errorDetail: { errorDetail: {
title: 'Error Detail',
titleWithId: 'Error #{id}',
noErrorSelected: 'No error selected.',
resolution: 'Resolved:',
pinnedToOriginalAccountId: 'Pinned to original account_id',
missingUpstreamRequestBody: 'Missing upstream request body',
failedToLoadRetryHistory: 'Failed to load retry history',
failedToUpdateResolvedStatus: 'Failed to update resolved status',
unsupportedRetryMode: 'Unsupported retry mode',
classificationKeys: {
phase: 'Phase',
owner: 'Owner',
source: 'Source',
retryable: 'Retryable',
resolvedAt: 'Resolved At',
resolvedBy: 'Resolved By',
resolvedRetryId: 'Resolved Retry',
retryCount: 'Retry Count'
},
source: {
upstream_http: 'Upstream HTTP'
},
upstreamKeys: {
status: 'Status',
message: 'Message',
detail: 'Detail',
upstreamErrors: 'Upstream Errors'
},
upstreamEvent: {
account: 'Account',
status: 'Status',
requestId: 'Request ID'
},
responsePreview: {
expand: 'Response (click to expand)',
collapse: 'Response (click to collapse)'
},
retryMeta: {
used: 'Used',
success: 'Success',
pinned: 'Pinned'
},
loading: 'Loading…', loading: 'Loading…',
requestId: 'Request ID', requestId: 'Request ID',
time: 'Time', time: 'Time',
...@@ -2044,8 +2142,10 @@ export default { ...@@ -2044,8 +2142,10 @@ export default {
basicInfo: 'Basic Info', basicInfo: 'Basic Info',
platform: 'Platform', platform: 'Platform',
model: 'Model', model: 'Model',
latency: 'Latency', group: 'Group',
ttft: 'TTFT', user: 'User',
account: 'Account',
latency: 'Request Duration',
businessLimited: 'Business Limited', businessLimited: 'Business Limited',
requestPath: 'Request Path', requestPath: 'Request Path',
timings: 'Timings', timings: 'Timings',
...@@ -2053,6 +2153,8 @@ export default { ...@@ -2053,6 +2153,8 @@ export default {
routing: 'Routing', routing: 'Routing',
upstream: 'Upstream', upstream: 'Upstream',
response: 'Response', response: 'Response',
classification: 'Classification',
notRetryable: 'Not recommended to retry',
retry: 'Retry', retry: 'Retry',
retryClient: 'Retry (Client)', retryClient: 'Retry (Client)',
retryUpstream: 'Retry (Upstream pinned)', retryUpstream: 'Retry (Upstream pinned)',
...@@ -2064,7 +2166,6 @@ export default { ...@@ -2064,7 +2166,6 @@ export default {
confirmRetry: 'Confirm Retry', confirmRetry: 'Confirm Retry',
retrySuccess: 'Retry succeeded', retrySuccess: 'Retry succeeded',
retryFailed: 'Retry failed', retryFailed: 'Retry failed',
na: 'N/A',
retryHint: 'Retry will resend the request with the same parameters', retryHint: 'Retry will resend the request with the same parameters',
retryClientHint: 'Use client retry (no account pinning)', retryClientHint: 'Use client retry (no account pinning)',
retryUpstreamHint: 'Use upstream pinned retry (pin to the error account)', retryUpstreamHint: 'Use upstream pinned retry (pin to the error account)',
...@@ -2072,8 +2173,33 @@ export default { ...@@ -2072,8 +2173,33 @@ export default {
retryNote1: 'Retry will use the same request body and parameters', retryNote1: 'Retry will use the same request body and parameters',
retryNote2: 'If the original request failed due to account issues, pinned retry may still fail', retryNote2: 'If the original request failed due to account issues, pinned retry may still fail',
retryNote3: 'Client retry will reselect an account', retryNote3: 'Client retry will reselect an account',
retryNote4: 'You can force retry for non-retryable errors, but it is not recommended',
confirmRetryMessage: 'Confirm retry this request?', confirmRetryMessage: 'Confirm retry this request?',
confirmRetryHint: 'Will resend with the same request parameters' confirmRetryHint: 'Will resend with the same request parameters',
forceRetry: 'I understand and want to force retry',
forceRetryHint: 'This error usually cannot be fixed by retry; check to proceed',
forceRetryNeedAck: 'Please check to force retry',
markResolved: 'Mark resolved',
markUnresolved: 'Mark unresolved',
viewRetries: 'Retry history',
retryHistory: 'Retry History',
tabOverview: 'Overview',
tabRetries: 'Retries',
tabRequest: 'Request',
tabResponse: 'Response',
responseBody: 'Response',
compareA: 'Compare A',
compareB: 'Compare B',
retrySummary: 'Retry Summary',
responseHintSucceeded: 'Showing succeeded retry response_preview (#{id})',
responseHintFallback: 'No succeeded retry found; showing stored error_body',
suggestion: 'Suggestion',
suggestUpstreamResolved: '✓ Upstream error resolved by retry; no action needed',
suggestUpstream: 'Upstream instability: check account status, consider switching accounts, or retry',
suggestRequest: 'Client request error: ask customer to fix request parameters',
suggestAuth: 'Auth failed: verify API key/credentials',
suggestPlatform: 'Platform error: prioritize investigation and fix',
suggestGeneric: 'See details for more context'
}, },
requestDetails: { requestDetails: {
title: 'Request Details', title: 'Request Details',
...@@ -2109,13 +2235,46 @@ export default { ...@@ -2109,13 +2235,46 @@ export default {
loading: 'Loading...', loading: 'Loading...',
empty: 'No alert events', empty: 'No alert events',
loadFailed: 'Failed to load alert events', loadFailed: 'Failed to load alert events',
status: {
firing: 'FIRING',
resolved: 'RESOLVED',
manualResolved: 'MANUAL RESOLVED'
},
detail: {
title: 'Alert Detail',
loading: 'Loading detail...',
empty: 'No detail',
loadFailed: 'Failed to load alert detail',
manualResolve: 'Mark as Resolved',
manualResolvedSuccess: 'Marked as manually resolved',
manualResolvedFailed: 'Failed to mark as manually resolved',
silence: 'Ignore Alert',
silenceSuccess: 'Alert silenced',
silenceFailed: 'Failed to silence alert',
viewRule: 'View Rule',
viewLogs: 'View Logs',
firedAt: 'Fired At',
resolvedAt: 'Resolved At',
ruleId: 'Rule ID',
dimensions: 'Dimensions',
historyTitle: 'History',
historyHint: 'Recent events with same rule + dimensions',
historyLoading: 'Loading history...',
historyEmpty: 'No history'
},
table: { table: {
time: 'Time', time: 'Time',
status: 'Status', status: 'Status',
severity: 'Severity', severity: 'Severity',
platform: 'Platform',
ruleId: 'Rule ID',
title: 'Title', title: 'Title',
duration: 'Duration',
metric: 'Metric / Threshold', metric: 'Metric / Threshold',
email: 'Email Sent' dimensions: 'Dimensions',
email: 'Email Sent',
emailSent: 'Sent',
emailIgnored: 'Ignored'
} }
}, },
alertRules: { alertRules: {
...@@ -2229,7 +2388,6 @@ export default { ...@@ -2229,7 +2388,6 @@ export default {
title: 'Alert Silencing (Maintenance Mode)', title: 'Alert Silencing (Maintenance Mode)',
enabled: 'Enable silencing', enabled: 'Enable silencing',
globalUntil: 'Silence until (RFC3339)', globalUntil: 'Silence until (RFC3339)',
untilPlaceholder: '2026-01-05T00:00:00Z',
untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).', untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).',
reason: 'Reason', reason: 'Reason',
reasonPlaceholder: 'e.g., planned maintenance', reasonPlaceholder: 'e.g., planned maintenance',
...@@ -2269,7 +2427,11 @@ export default { ...@@ -2269,7 +2427,11 @@ export default {
lockKeyRequired: 'Distributed lock key is required when lock is enabled', lockKeyRequired: 'Distributed lock key is required when lock is enabled',
lockKeyPrefix: 'Distributed lock key must start with "{prefix}"', lockKeyPrefix: 'Distributed lock key must start with "{prefix}"',
lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts', lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts',
lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds' lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds',
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100'
} }
}, },
email: { email: {
...@@ -2334,8 +2496,6 @@ export default { ...@@ -2334,8 +2496,6 @@ export default {
metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red', metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red',
slaMinPercent: 'SLA Minimum Percentage', slaMinPercent: 'SLA Minimum Percentage',
slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)', slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)',
latencyP99MaxMs: 'Latency P99 Maximum (ms)',
latencyP99MaxMsHint: 'Latency P99 above this value will be displayed in red (default: 2000ms)',
ttftP99MaxMs: 'TTFT P99 Maximum (ms)', ttftP99MaxMs: 'TTFT P99 Maximum (ms)',
ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)', ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)',
requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)', requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)',
...@@ -2354,9 +2514,28 @@ export default { ...@@ -2354,9 +2514,28 @@ export default {
aggregation: 'Pre-aggregation Tasks', aggregation: 'Pre-aggregation Tasks',
enableAggregation: 'Enable Pre-aggregation', enableAggregation: 'Enable Pre-aggregation',
aggregationHint: 'Pre-aggregation improves query performance for long time windows', aggregationHint: 'Pre-aggregation improves query performance for long time windows',
errorFiltering: 'Error Filtering',
ignoreCountTokensErrors: 'Ignore count_tokens errors',
ignoreCountTokensErrorsHint: 'When enabled, errors from count_tokens requests will not be written to the error log.',
ignoreContextCanceled: 'Ignore client disconnect errors',
ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.',
ignoreNoAvailableAccounts: 'Ignore no available accounts errors',
ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).',
autoRefresh: 'Auto Refresh',
enableAutoRefresh: 'Enable auto refresh',
enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.',
refreshInterval: 'Refresh Interval',
refreshInterval15s: '15 seconds',
refreshInterval30s: '30 seconds',
refreshInterval60s: '60 seconds',
autoRefreshCountdown: 'Auto refresh: {seconds}s',
validation: { validation: {
title: 'Please fix the following issues', title: 'Please fix the following issues',
retentionDaysRange: 'Retention days must be between 1-365 days' retentionDaysRange: 'Retention days must be between 1-365 days',
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100'
} }
}, },
concurrency: { concurrency: {
...@@ -2394,7 +2573,7 @@ export default { ...@@ -2394,7 +2573,7 @@ export default {
tooltips: { tooltips: {
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.', totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
latencyHistogram: 'Latency distribution (duration_ms) for successful requests.', latencyHistogram: 'Request duration distribution (ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.', errorDistribution: 'Error distribution by status code.',
goroutines: goroutines:
...@@ -2409,7 +2588,7 @@ export default { ...@@ -2409,7 +2588,7 @@ export default {
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).', sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
errors: 'Error statistics, including total errors, error rate, and upstream error rate.', errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).', upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).',
latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.', latency: 'Request duration statistics, including p50, p90, p95, p99 percentiles.',
ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.', ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.',
health: 'System health score (0-100), considering SLA, error rate, and resource usage.' health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
}, },
......
...@@ -126,6 +126,8 @@ export default { ...@@ -126,6 +126,8 @@ export default {
all: '全部', all: '全部',
none: '', none: '',
noData: '暂无数据', noData: '暂无数据',
expand: '展开',
collapse: '收起',
success: '成功', success: '成功',
error: '错误', error: '错误',
critical: '严重', critical: '严重',
...@@ -426,6 +428,9 @@ export default { ...@@ -426,6 +428,9 @@ export default {
totalCost: '总消费', totalCost: '总消费',
standardCost: '标准', standardCost: '标准',
actualCost: '实际', actualCost: '实际',
userBilled: '用户扣费',
accountBilled: '账号计费',
accountMultiplier: '账号倍率',
avgDuration: '平均耗时', avgDuration: '平均耗时',
inSelectedRange: '所选范围内', inSelectedRange: '所选范围内',
perRequest: '每次请求', perRequest: '每次请求',
...@@ -1109,6 +1114,7 @@ export default { ...@@ -1109,6 +1114,7 @@ export default {
concurrencyStatus: '并发', concurrencyStatus: '并发',
notes: '备注', notes: '备注',
priority: '优先级', priority: '优先级',
billingRateMultiplier: '账号倍率',
weight: '权重', weight: '权重',
status: '状态', status: '状态',
schedulable: '调度', schedulable: '调度',
...@@ -1360,6 +1366,8 @@ export default { ...@@ -1360,6 +1366,8 @@ export default {
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越小的账号优先使用', priorityHint: '优先级越小的账号优先使用',
billingRateMultiplier: '账号计费倍率',
billingRateMultiplierHint: '>=0,0 表示该账号计费为 0;仅影响账号计费口径',
expiresAt: '过期时间', expiresAt: '过期时间',
expiresAtHint: '留空表示不过期', expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高', higherPriorityFirst: '数值越小优先级越高',
...@@ -1713,6 +1721,7 @@ export default { ...@@ -1713,6 +1721,7 @@ export default {
address: '地址', address: '地址',
status: '状态', status: '状态',
accounts: '账号数', accounts: '账号数',
latency: '延迟',
actions: '操作', actions: '操作',
nameLabel: '名称', nameLabel: '名称',
namePlaceholder: '请输入代理名称', namePlaceholder: '请输入代理名称',
...@@ -1749,11 +1758,32 @@ export default { ...@@ -1749,11 +1758,32 @@ export default {
enterProxyName: '请输入代理名称', enterProxyName: '请输入代理名称',
optionalAuth: '可选认证信息', optionalAuth: '可选认证信息',
leaveEmptyToKeep: '留空保持不变', leaveEmptyToKeep: '留空保持不变',
form: {
hostPlaceholder: '请输入主机地址',
portPlaceholder: '请输入端口'
},
noProxiesYet: '暂无代理', noProxiesYet: '暂无代理',
createFirstProxy: '添加您的第一个代理以开始使用。', createFirstProxy: '添加您的第一个代理以开始使用。',
testConnection: '测试连接', testConnection: '测试连接',
batchTest: '批量测试', batchTest: '批量测试',
testFailed: '失败', testFailed: '失败',
latencyFailed: '链接失败',
batchTestEmpty: '暂无可测试的代理',
batchTestDone: '批量测试完成,共测试 {count} 个代理',
batchTestFailed: '批量测试失败',
batchDeleteAction: '删除',
batchDelete: '批量删除',
batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。',
batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个',
batchDeleteSkipped: '已跳过 {skipped} 个代理',
batchDeleteFailed: '批量删除失败',
deleteBlockedInUse: '该代理已有账号使用,无法删除',
accountsTitle: '使用该IP的账号',
accountsEmpty: '暂无账号使用此代理',
accountsFailed: '获取账号列表失败',
accountName: '账号名称',
accountPlatform: '所属平台',
accountNotes: '备注',
// Batch import // Batch import
standardAdd: '标准添加', standardAdd: '标准添加',
batchAdd: '快捷添加', batchAdd: '快捷添加',
...@@ -2003,10 +2033,8 @@ export default { ...@@ -2003,10 +2033,8 @@ export default {
noSystemMetrics: '尚未收集系统指标。', noSystemMetrics: '尚未收集系统指标。',
collectedAt: '采集时间:', collectedAt: '采集时间:',
window: '窗口', window: '窗口',
cpu: 'CPU',
memory: '内存', memory: '内存',
db: '数据库', db: '数据库',
redis: 'Redis',
goroutines: '协程', goroutines: '协程',
jobs: '后台任务', jobs: '后台任务',
jobsHelp: '点击“明细”查看任务心跳与报错信息', jobsHelp: '点击“明细”查看任务心跳与报错信息',
...@@ -2032,7 +2060,7 @@ export default { ...@@ -2032,7 +2060,7 @@ export default {
totalRequests: '总请求', totalRequests: '总请求',
avgQps: '平均 QPS', avgQps: '平均 QPS',
avgTps: '平均 TPS', avgTps: '平均 TPS',
avgLatency: '平均延迟', avgLatency: '平均请求时长',
avgTtft: '平均首字延迟', avgTtft: '平均首字延迟',
exceptions: '异常数', exceptions: '异常数',
requestErrors: '请求错误', requestErrors: '请求错误',
...@@ -2044,7 +2072,7 @@ export default { ...@@ -2044,7 +2072,7 @@ export default {
errors: '错误', errors: '错误',
errorRate: '错误率:', errorRate: '错误率:',
upstreamRate: '上游错误率:', upstreamRate: '上游错误率:',
latencyDuration: '延迟(毫秒)', latencyDuration: '请求时长(毫秒)',
ttftLabel: '首字延迟(毫秒)', ttftLabel: '首字延迟(毫秒)',
p50: 'p50', p50: 'p50',
p90: 'p90', p90: 'p90',
...@@ -2052,7 +2080,6 @@ export default { ...@@ -2052,7 +2080,6 @@ export default {
p99: 'p99', p99: 'p99',
avg: 'avg', avg: 'avg',
max: 'max', max: 'max',
qps: 'QPS',
requests: '请求数', requests: '请求数',
requestsTitle: '请求', requestsTitle: '请求',
upstream: '上游', upstream: '上游',
...@@ -2064,7 +2091,7 @@ export default { ...@@ -2064,7 +2091,7 @@ export default {
failedToLoadData: '加载运维数据失败', failedToLoadData: '加载运维数据失败',
failedToLoadOverview: '加载概览数据失败', failedToLoadOverview: '加载概览数据失败',
failedToLoadThroughputTrend: '加载吞吐趋势失败', failedToLoadThroughputTrend: '加载吞吐趋势失败',
failedToLoadLatencyHistogram: '加载延迟分布失败', failedToLoadLatencyHistogram: '加载请求时长分布失败',
failedToLoadErrorTrend: '加载错误趋势失败', failedToLoadErrorTrend: '加载错误趋势失败',
failedToLoadErrorDistribution: '加载错误分布失败', failedToLoadErrorDistribution: '加载错误分布失败',
failedToLoadErrorDetail: '加载错误详情失败', failedToLoadErrorDetail: '加载错误详情失败',
...@@ -2072,7 +2099,7 @@ export default { ...@@ -2072,7 +2099,7 @@ export default {
tpsK: 'TPS(千)', tpsK: 'TPS(千)',
top: '最高:', top: '最高:',
throughputTrend: '吞吐趋势', throughputTrend: '吞吐趋势',
latencyHistogram: '延迟分布', latencyHistogram: '请求时长分布',
errorTrend: '错误趋势', errorTrend: '错误趋势',
errorDistribution: '错误分布', errorDistribution: '错误分布',
// Health Score & Diagnosis // Health Score & Diagnosis
...@@ -2087,7 +2114,9 @@ export default { ...@@ -2087,7 +2114,9 @@ export default {
'30m': '近30分钟', '30m': '近30分钟',
'1h': '近1小时', '1h': '近1小时',
'6h': '近6小时', '6h': '近6小时',
'24h': '近24小时' '24h': '近24小时',
'7d': '近7天',
'30d': '近30天'
}, },
fullscreen: { fullscreen: {
enter: '进入全屏' enter: '进入全屏'
...@@ -2116,15 +2145,8 @@ export default { ...@@ -2116,15 +2145,8 @@ export default {
memoryHigh: '内存使用率偏高 ({usage}%)', memoryHigh: '内存使用率偏高 ({usage}%)',
memoryHighImpact: '内存压力较大,需要关注', memoryHighImpact: '内存压力较大,需要关注',
memoryHighAction: '监控内存趋势,检查是否有内存泄漏', memoryHighAction: '监控内存趋势,检查是否有内存泄漏',
// Latency diagnostics
latencyCritical: '响应延迟严重过高 ({latency}ms)',
latencyCriticalImpact: '用户体验极差,大量请求超时',
latencyCriticalAction: '检查慢查询、数据库索引、网络延迟和上游服务',
latencyHigh: '响应延迟偏高 ({latency}ms)',
latencyHighImpact: '用户体验下降,需要优化',
latencyHighAction: '分析慢请求日志,优化数据库查询和业务逻辑',
ttftHigh: '首字节时间偏高 ({ttft}ms)', ttftHigh: '首字节时间偏高 ({ttft}ms)',
ttftHighImpact: '用户感知延迟增加', ttftHighImpact: '用户感知时长增加',
ttftHighAction: '优化请求处理流程,减少前置逻辑耗时', ttftHighAction: '优化请求处理流程,减少前置逻辑耗时',
// Error rate diagnostics // Error rate diagnostics
upstreamCritical: '上游错误率严重偏高 ({rate}%)', upstreamCritical: '上游错误率严重偏高 ({rate}%)',
...@@ -2142,13 +2164,13 @@ export default { ...@@ -2142,13 +2164,13 @@ export default {
// SLA diagnostics // SLA diagnostics
slaCritical: 'SLA 严重低于目标 ({sla}%)', slaCritical: 'SLA 严重低于目标 ({sla}%)',
slaCriticalImpact: '用户体验严重受损', slaCriticalImpact: '用户体验严重受损',
slaCriticalAction: '紧急排查错误和延迟问题,考虑限流保护', slaCriticalAction: '紧急排查错误原因,必要时采取限流保护',
slaLow: 'SLA 低于目标 ({sla}%)', slaLow: 'SLA 低于目标 ({sla}%)',
slaLowImpact: '需要关注服务质量', slaLowImpact: '需要关注服务质量',
slaLowAction: '分析SLA下降原因,优化系统性能', slaLowAction: '分析SLA下降原因,优化系统性能',
// Health score diagnostics // Health score diagnostics
healthCritical: '综合健康评分过低 ({score})', healthCritical: '综合健康评分过低 ({score})',
healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与延迟', healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与资源使用情况',
healthCriticalAction: '全面检查系统状态,优先处理critical级别问题', healthCriticalAction: '全面检查系统状态,优先处理critical级别问题',
healthLow: '综合健康评分偏低 ({score})', healthLow: '综合健康评分偏低 ({score})',
healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率', healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率',
...@@ -2159,27 +2181,106 @@ export default { ...@@ -2159,27 +2181,106 @@ export default {
// Error Log // Error Log
errorLog: { errorLog: {
timeId: '时间 / ID', timeId: '时间 / ID',
commonErrors: {
contextDeadlineExceeded: '请求超时',
connectionRefused: '连接被拒绝',
rateLimit: '触发限流'
},
time: '时间',
type: '类型',
context: '上下文', context: '上下文',
platform: '平台',
model: '模型',
group: '分组',
user: '用户',
userId: '用户 ID',
account: '账号',
accountId: '账号 ID',
status: '状态码', status: '状态码',
message: '消息', message: '响应内容',
latency: '延迟', latency: '请求时长',
action: '操作', action: '操作',
noErrors: '该窗口内暂无错误。', noErrors: '该窗口内暂无错误。',
grp: 'GRP:', grp: 'GRP:',
acc: 'ACC:', acc: 'ACC:',
details: '详情', details: '详情',
phase: '阶段' phase: '阶段',
id: 'ID:',
typeUpstream: '上游',
typeRequest: '请求',
typeAuth: '认证',
typeRouting: '路由',
typeInternal: '内部'
}, },
// Error Details Modal // Error Details Modal
errorDetails: { errorDetails: {
upstreamErrors: '上游错误', upstreamErrors: '上游错误',
requestErrors: '请求错误', requestErrors: '请求错误',
unresolved: '未解决',
resolved: '已解决',
viewErrors: '错误',
viewExcluded: '排除项',
statusCodeOther: '其他',
owner: {
provider: '服务商',
client: '客户端',
platform: '平台'
},
phase: {
request: '请求',
auth: '认证',
routing: '路由',
upstream: '上游',
network: '网络',
internal: '内部'
},
total: '总计:', total: '总计:',
searchPlaceholder: '搜索 request_id / client_request_id / message', searchPlaceholder: '搜索 request_id / client_request_id / message',
accountIdPlaceholder: 'account_id'
}, },
// Error Detail Modal // Error Detail Modal
errorDetail: { errorDetail: {
title: '错误详情',
titleWithId: '错误 #{id}',
noErrorSelected: '未选择错误。',
resolution: '已解决:',
pinnedToOriginalAccountId: '固定到原 account_id',
missingUpstreamRequestBody: '缺少上游请求体',
failedToLoadRetryHistory: '加载重试历史失败',
failedToUpdateResolvedStatus: '更新解决状态失败',
unsupportedRetryMode: '不支持的重试模式',
classificationKeys: {
phase: '阶段',
owner: '归属方',
source: '来源',
retryable: '可重试',
resolvedAt: '解决时间',
resolvedBy: '解决人',
resolvedRetryId: '解决重试ID',
retryCount: '重试次数'
},
source: {
upstream_http: '上游 HTTP'
},
upstreamKeys: {
status: '状态码',
message: '消息',
detail: '详情',
upstreamErrors: '上游错误列表'
},
upstreamEvent: {
account: '账号',
status: '状态码',
requestId: '请求ID'
},
responsePreview: {
expand: '响应内容(点击展开)',
collapse: '响应内容(点击收起)'
},
retryMeta: {
used: '使用账号',
success: '成功',
pinned: '固定账号'
},
loading: '加载中…', loading: '加载中…',
requestId: '请求 ID', requestId: '请求 ID',
time: '时间', time: '时间',
...@@ -2189,8 +2290,10 @@ export default { ...@@ -2189,8 +2290,10 @@ export default {
basicInfo: '基本信息', basicInfo: '基本信息',
platform: '平台', platform: '平台',
model: '模型', model: '模型',
latency: '延迟', group: '分组',
ttft: 'TTFT', user: '用户',
account: '账号',
latency: '请求时长',
businessLimited: '业务限制', businessLimited: '业务限制',
requestPath: '请求路径', requestPath: '请求路径',
timings: '时序信息', timings: '时序信息',
...@@ -2198,6 +2301,8 @@ export default { ...@@ -2198,6 +2301,8 @@ export default {
routing: '路由', routing: '路由',
upstream: '上游', upstream: '上游',
response: '响应', response: '响应',
classification: '错误分类',
notRetryable: '此错误不建议重试',
retry: '重试', retry: '重试',
retryClient: '重试(客户端)', retryClient: '重试(客户端)',
retryUpstream: '重试(上游固定)', retryUpstream: '重试(上游固定)',
...@@ -2209,7 +2314,6 @@ export default { ...@@ -2209,7 +2314,6 @@ export default {
confirmRetry: '确认重试', confirmRetry: '确认重试',
retrySuccess: '重试成功', retrySuccess: '重试成功',
retryFailed: '重试失败', retryFailed: '重试失败',
na: 'N/A',
retryHint: '重试将使用相同的请求参数重新发送请求', retryHint: '重试将使用相同的请求参数重新发送请求',
retryClientHint: '使用客户端重试(不固定账号)', retryClientHint: '使用客户端重试(不固定账号)',
retryUpstreamHint: '使用上游固定重试(固定到错误的账号)', retryUpstreamHint: '使用上游固定重试(固定到错误的账号)',
...@@ -2217,8 +2321,33 @@ export default { ...@@ -2217,8 +2321,33 @@ export default {
retryNote1: '重试会使用相同的请求体和参数', retryNote1: '重试会使用相同的请求体和参数',
retryNote2: '如果原请求失败是因为账号问题,固定重试可能仍会失败', retryNote2: '如果原请求失败是因为账号问题,固定重试可能仍会失败',
retryNote3: '客户端重试会重新选择账号', retryNote3: '客户端重试会重新选择账号',
retryNote4: '对不可重试的错误可以强制重试,但不推荐',
confirmRetryMessage: '确认要重试该请求吗?', confirmRetryMessage: '确认要重试该请求吗?',
confirmRetryHint: '将使用相同的请求参数重新发送' confirmRetryHint: '将使用相同的请求参数重新发送',
forceRetry: '我已确认并理解强制重试风险',
forceRetryHint: '此错误类型通常不可通过重试解决;如仍需重试请勾选确认',
forceRetryNeedAck: '请先勾选确认再强制重试',
markResolved: '标记已解决',
markUnresolved: '标记未解决',
viewRetries: '重试历史',
retryHistory: '重试历史',
tabOverview: '概览',
tabRetries: '重试历史',
tabRequest: '请求详情',
tabResponse: '响应详情',
responseBody: '响应详情',
compareA: '对比 A',
compareB: '对比 B',
retrySummary: '重试摘要',
responseHintSucceeded: '展示重试成功的 response_preview(#{id})',
responseHintFallback: '没有成功的重试结果,展示存储的 error_body',
suggestion: '处理建议',
suggestUpstreamResolved: '✓ 上游错误已通过重试解决,无需人工介入',
suggestUpstream: '⚠️ 上游服务不稳定,建议:检查上游账号状态 / 考虑切换账号 / 再次重试',
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
suggestPlatform: '🚨 平台错误,建议立即排查修复',
suggestGeneric: '查看详情了解更多信息'
}, },
requestDetails: { requestDetails: {
title: '请求明细', title: '请求明细',
...@@ -2254,13 +2383,46 @@ export default { ...@@ -2254,13 +2383,46 @@ export default {
loading: '加载中...', loading: '加载中...',
empty: '暂无告警事件', empty: '暂无告警事件',
loadFailed: '加载告警事件失败', loadFailed: '加载告警事件失败',
status: {
firing: '告警中',
resolved: '已恢复',
manualResolved: '手动已解决'
},
detail: {
title: '告警详情',
loading: '加载详情中...',
empty: '暂无详情',
loadFailed: '加载告警详情失败',
manualResolve: '标记为已解决',
manualResolvedSuccess: '已标记为手动解决',
manualResolvedFailed: '标记为手动解决失败',
silence: '忽略此告警',
silenceSuccess: '已静默该告警',
silenceFailed: '静默失败',
viewRule: '查看规则',
viewLogs: '查看相关日志',
firedAt: '触发时间',
resolvedAt: '解决时间',
ruleId: '规则 ID',
dimensions: '维度信息',
historyTitle: '历史记录',
historyHint: '同一规则 + 相同维度的最近事件',
historyLoading: '加载历史中...',
historyEmpty: '暂无历史记录'
},
table: { table: {
time: '时间', time: '时间',
status: '状态', status: '状态',
severity: '级别', severity: '级别',
platform: '平台',
ruleId: '规则ID',
title: '标题', title: '标题',
duration: '持续时间',
metric: '指标 / 阈值', metric: '指标 / 阈值',
email: '邮件已发送' dimensions: '维度',
email: '邮件已发送',
emailSent: '已发送',
emailIgnored: '已忽略'
} }
}, },
alertRules: { alertRules: {
...@@ -2288,8 +2450,8 @@ export default { ...@@ -2288,8 +2450,8 @@ export default {
successRate: '成功率 (%)', successRate: '成功率 (%)',
errorRate: '错误率 (%)', errorRate: '错误率 (%)',
upstreamErrorRate: '上游错误率 (%)', upstreamErrorRate: '上游错误率 (%)',
p95: 'P95 延迟 (ms)', p95: 'P95 请求时长 (ms)',
p99: 'P99 延迟 (ms)', p99: 'P99 请求时长 (ms)',
cpu: 'CPU 使用率 (%)', cpu: 'CPU 使用率 (%)',
memory: '内存使用率 (%)', memory: '内存使用率 (%)',
queueDepth: '并发排队深度', queueDepth: '并发排队深度',
...@@ -2374,7 +2536,6 @@ export default { ...@@ -2374,7 +2536,6 @@ export default {
title: '告警静默(维护模式)', title: '告警静默(维护模式)',
enabled: '启用静默', enabled: '启用静默',
globalUntil: '静默截止时间(RFC3339)', globalUntil: '静默截止时间(RFC3339)',
untilPlaceholder: '2026-01-05T00:00:00Z',
untilHint: '建议填写截止时间,避免忘记关闭静默。', untilHint: '建议填写截止时间,避免忘记关闭静默。',
reason: '原因', reason: '原因',
reasonPlaceholder: '例如:计划维护', reasonPlaceholder: '例如:计划维护',
...@@ -2414,7 +2575,11 @@ export default { ...@@ -2414,7 +2575,11 @@ export default {
lockKeyRequired: '启用分布式锁时必须填写 Lock Key', lockKeyRequired: '启用分布式锁时必须填写 Lock Key',
lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头', lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头',
lockKeyHint: '建议以「{prefix}」开头以避免冲突', lockKeyHint: '建议以「{prefix}」开头以避免冲突',
lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间' lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间',
slaMinPercentRange: 'SLA 最低值必须在 0-100 之间',
ttftP99MaxRange: 'TTFT P99 最大值必须大于或等于 0',
requestErrorRateMaxRange: '请求错误率最大值必须在 0-100 之间',
upstreamErrorRateMaxRange: '上游错误率最大值必须在 0-100 之间'
} }
}, },
email: { email: {
...@@ -2479,8 +2644,6 @@ export default { ...@@ -2479,8 +2644,6 @@ export default {
metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示', metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示',
slaMinPercent: 'SLA最低百分比', slaMinPercent: 'SLA最低百分比',
slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)', slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)',
latencyP99MaxMs: '延迟P99最大值(毫秒)',
latencyP99MaxMsHint: '延迟P99高于此值时显示为红色(默认:2000ms)',
ttftP99MaxMs: 'TTFT P99最大值(毫秒)', ttftP99MaxMs: 'TTFT P99最大值(毫秒)',
ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)', ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)',
requestErrorRateMaxPercent: '请求错误率最大值(%)', requestErrorRateMaxPercent: '请求错误率最大值(%)',
...@@ -2499,9 +2662,28 @@ export default { ...@@ -2499,9 +2662,28 @@ export default {
aggregation: '预聚合任务', aggregation: '预聚合任务',
enableAggregation: '启用预聚合任务', enableAggregation: '启用预聚合任务',
aggregationHint: '预聚合可提升长时间窗口查询性能', aggregationHint: '预聚合可提升长时间窗口查询性能',
errorFiltering: '错误过滤',
ignoreCountTokensErrors: '忽略 count_tokens 错误',
ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。',
ignoreContextCanceled: '忽略客户端断连错误',
ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
ignoreNoAvailableAccounts: '忽略无可用账号错误',
ignoreNoAvailableAccountsHint: '启用后,“No available accounts” 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
autoRefresh: '自动刷新',
enableAutoRefresh: '启用自动刷新',
enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。',
refreshInterval: '刷新间隔',
refreshInterval15s: '15 秒',
refreshInterval30s: '30 秒',
refreshInterval60s: '60 秒',
autoRefreshCountdown: '自动刷新:{seconds}s',
validation: { validation: {
title: '请先修正以下问题', title: '请先修正以下问题',
retentionDaysRange: '保留天数必须在1-365天之间' retentionDaysRange: '保留天数必须在1-365天之间',
slaMinPercentRange: 'SLA最低百分比必须在0-100之间',
ttftP99MaxRange: 'TTFT P99最大值必须大于等于0',
requestErrorRateMaxRange: '请求错误率最大值必须在0-100之间',
upstreamErrorRateMaxRange: '上游错误率最大值必须在0-100之间'
} }
}, },
concurrency: { concurrency: {
...@@ -2539,12 +2721,12 @@ export default { ...@@ -2539,12 +2721,12 @@ export default {
tooltips: { tooltips: {
totalRequests: '当前时间窗口内的总请求数和Token消耗量。', totalRequests: '当前时间窗口内的总请求数和Token消耗量。',
throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。', throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。',
latencyHistogram: '成功请求的延迟分布(毫秒)。', latencyHistogram: '成功请求的请求时长分布(毫秒)。',
errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。', errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。',
errorDistribution: '按状态码统计的错误分布。', errorDistribution: '按状态码统计的错误分布。',
upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。', upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。',
goroutines: goroutines:
'Go 运行时的协程数量(轻量级线程)。没有绝对安全值,建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列/延迟上升时,优先排查阻塞/泄漏。', 'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。',
cpu: 'CPU 使用率,显示系统处理器的负载情况。', cpu: 'CPU 使用率,显示系统处理器的负载情况。',
memory: '内存使用率,包括已使用和总可用内存。', memory: '内存使用率,包括已使用和总可用内存。',
db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。', db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。',
...@@ -2554,7 +2736,7 @@ export default { ...@@ -2554,7 +2736,7 @@ export default {
tokens: '当前时间窗口内处理的总Token数量。', tokens: '当前时间窗口内处理的总Token数量。',
sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。', sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。',
errors: '错误统计,包括总错误数、错误率和上游错误率。', errors: '错误统计,包括总错误数、错误率和上游错误率。',
latency: '请求延迟统计,包括 p50、p90、p95、p99 等百分位数。', latency: '请求时长统计,包括 p50、p90、p95、p99 等百分位数。',
ttft: '首Token延迟(Time To First Token),衡量流式响应的首字节返回速度。', ttft: '首Token延迟(Time To First Token),衡量流式响应的首字节返回速度。',
health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。' health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。'
}, },
......
...@@ -345,7 +345,7 @@ ...@@ -345,7 +345,7 @@
.modal-overlay { .modal-overlay {
@apply fixed inset-0 z-50; @apply fixed inset-0 z-50;
@apply bg-black/50 backdrop-blur-sm; @apply bg-black/50 backdrop-blur-sm;
@apply flex items-center justify-center p-4; @apply flex items-center justify-center p-2 sm:p-4;
} }
.modal-content { .modal-content {
......
...@@ -364,10 +364,21 @@ export interface Proxy { ...@@ -364,10 +364,21 @@ export interface Proxy {
password?: string | null password?: string | null
status: 'active' | 'inactive' status: 'active' | 'inactive'
account_count?: number // Number of accounts using this proxy account_count?: number // Number of accounts using this proxy
latency_ms?: number
latency_status?: 'success' | 'failed'
latency_message?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
export interface ProxyAccountSummary {
id: number
name: string
platform: AccountPlatform
type: AccountType
notes?: string | null
}
// Gemini credentials structure for OAuth and API Key authentication // Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials { export interface GeminiCredentials {
// API Key authentication // API Key authentication
...@@ -428,6 +439,7 @@ export interface Account { ...@@ -428,6 +439,7 @@ export interface Account {
concurrency: number concurrency: number
current_concurrency?: number // Real-time concurrency count from Redis current_concurrency?: number // Real-time concurrency count from Redis
priority: number priority: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
status: 'active' | 'inactive' | 'error' status: 'active' | 'inactive' | 'error'
error_message: string | null error_message: string | null
last_used_at: string | null last_used_at: string | null
...@@ -457,7 +469,9 @@ export interface Account { ...@@ -457,7 +469,9 @@ export interface Account {
export interface WindowStats { export interface WindowStats {
requests: number requests: number
tokens: number tokens: number
cost: number cost: number // Account cost (account multiplier)
standard_cost?: number
user_cost?: number
} }
export interface UsageProgress { export interface UsageProgress {
...@@ -522,6 +536,7 @@ export interface CreateAccountRequest { ...@@ -522,6 +536,7 @@ export interface CreateAccountRequest {
proxy_id?: number | null proxy_id?: number | null
concurrency?: number concurrency?: number
priority?: number priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
group_ids?: number[] group_ids?: number[]
expires_at?: number | null expires_at?: number | null
auto_pause_on_expired?: boolean auto_pause_on_expired?: boolean
...@@ -537,6 +552,7 @@ export interface UpdateAccountRequest { ...@@ -537,6 +552,7 @@ export interface UpdateAccountRequest {
proxy_id?: number | null proxy_id?: number | null
concurrency?: number concurrency?: number
priority?: number priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
schedulable?: boolean schedulable?: boolean
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
...@@ -593,6 +609,7 @@ export interface UsageLog { ...@@ -593,6 +609,7 @@ export interface UsageLog {
total_cost: number total_cost: number
actual_cost: number actual_cost: number
rate_multiplier: number rate_multiplier: number
account_rate_multiplier?: number | null
stream: boolean stream: boolean
duration_ms: number duration_ms: number
...@@ -852,23 +869,27 @@ export interface AccountUsageHistory { ...@@ -852,23 +869,27 @@ export interface AccountUsageHistory {
requests: number requests: number
tokens: number tokens: number
cost: number cost: number
actual_cost: number actual_cost: number // Account cost (account multiplier)
user_cost: number // User/API key billed cost (group multiplier)
} }
export interface AccountUsageSummary { export interface AccountUsageSummary {
days: number days: number
actual_days_used: number actual_days_used: number
total_cost: number total_cost: number // Account cost (account multiplier)
total_user_cost: number
total_standard_cost: number total_standard_cost: number
total_requests: number total_requests: number
total_tokens: number total_tokens: number
avg_daily_cost: number avg_daily_cost: number // Account cost
avg_daily_user_cost: number
avg_daily_requests: number avg_daily_requests: number
avg_daily_tokens: number avg_daily_tokens: number
avg_duration_ms: number avg_duration_ms: number
today: { today: {
date: string date: string
cost: number cost: number
user_cost: number
requests: number requests: number
tokens: number tokens: number
} | null } | null
...@@ -876,6 +897,7 @@ export interface AccountUsageSummary { ...@@ -876,6 +897,7 @@ export interface AccountUsageSummary {
date: string date: string
label: string label: string
cost: number cost: number
user_cost: number
requests: number requests: number
} | null } | null
highest_request_day: { highest_request_day: {
...@@ -883,6 +905,7 @@ export interface AccountUsageSummary { ...@@ -883,6 +905,7 @@ export interface AccountUsageSummary {
label: string label: string
requests: number requests: number
cost: number cost: number
user_cost: number
} | null } | null
} }
......
...@@ -61,6 +61,11 @@ ...@@ -61,6 +61,11 @@
<template #cell-usage="{ row }"> <template #cell-usage="{ row }">
<AccountUsageCell :account="row" /> <AccountUsageCell :account="row" />
</template> </template>
<template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
</span>
</template>
<template #cell-priority="{ value }"> <template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template> </template>
...@@ -120,7 +125,7 @@ ...@@ -120,7 +125,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
...@@ -190,10 +195,11 @@ const cols = computed(() => { ...@@ -190,10 +195,11 @@ const cols = computed(() => {
if (!authStore.isSimpleMode) { if (!authStore.isSimpleMode) {
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false }) c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
} }
c.push( c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true }, { key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } { key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
...@@ -202,7 +208,56 @@ const cols = computed(() => { ...@@ -202,7 +208,56 @@ const cols = computed(() => {
}) })
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true } const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true } const openMenu = (a: Account, e: MouseEvent) => {
menu.acc = a
const target = e.currentTarget as HTMLElement
if (target) {
const rect = target.getBoundingClientRect()
const menuWidth = 200
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left, top
if (viewportWidth < 768) {
// 居中显示,水平位置
left = Math.max(padding, Math.min(
rect.left + rect.width / 2 - menuWidth / 2,
viewportWidth - menuWidth - padding
))
// 优先显示在按钮下方
top = rect.bottom + 4
// 如果下方空间不够,显示在上方
if (top + menuHeight > viewportHeight - padding) {
top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
}
} else {
left = Math.max(padding, Math.min(
e.clientX - menuWidth,
viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
}
}
menu.pos = { top, left }
} else {
menu.pos = { top: e.clientY, left: e.clientX - 200 }
}
menu.show = true
}
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } } const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
...@@ -360,5 +415,14 @@ const isExpired = (value: number | null) => { ...@@ -360,5 +415,14 @@ const isExpired = (value: number | null) => {
return value * 1000 <= Date.now() return value * 1000 <= Date.now()
} }
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } }) // 滚动时关闭菜单
const handleScroll = () => {
menu.show = false
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) }; window.addEventListener('scroll', handleScroll, true) })
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll, true)
})
</script> </script>
...@@ -51,6 +51,24 @@ ...@@ -51,6 +51,24 @@
> >
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" /> <Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button> </button>
<button
@click="handleBatchTest"
:disabled="batchTesting || loading"
class="btn btn-secondary"
:title="t('admin.proxies.testConnection')"
>
<Icon name="play" size="md" class="mr-2" />
{{ t('admin.proxies.testConnection') }}
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
class="btn btn-danger"
:title="t('admin.proxies.batchDeleteAction')"
>
<Icon name="trash" size="md" class="mr-2" />
{{ t('admin.proxies.batchDeleteAction') }}
</button>
<button @click="showCreateModal = true" class="btn btn-primary"> <button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }} {{ t('admin.proxies.createProxy') }}
...@@ -61,6 +79,26 @@ ...@@ -61,6 +79,26 @@
<template #table> <template #table>
<DataTable :columns="columns" :data="proxies" :loading="loading"> <DataTable :columns="columns" :data="proxies" :loading="loading">
<template #header-select>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="allVisibleSelected"
@click.stop
@change="toggleSelectAllVisible($event)"
/>
</template>
<template #cell-select="{ row }">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="selectedProxyIds.has(row.id)"
@click.stop
@change="toggleSelectRow(row.id, $event)"
/>
</template>
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -79,17 +117,43 @@ ...@@ -79,17 +117,43 @@
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code> <code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
</template> </template>
<template #cell-status="{ value }"> <template #cell-account_count="{ row, value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']"> <button
{{ t('admin.accounts.status.' + value) }} v-if="(value || 0) > 0"
type="button"
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-primary-700 hover:bg-gray-200 dark:bg-dark-600 dark:text-primary-300 dark:hover:bg-dark-500"
@click="openAccountsModal(row)"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</button>
<span
v-else
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ t('admin.groups.accountsCount', { count: 0 }) }}
</span> </span>
</template> </template>
<template #cell-account_count="{ value }"> <template #cell-latency="{ row }">
<span <span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300" v-if="row.latency_status === 'failed'"
class="badge badge-danger"
:title="row.latency_message || undefined"
> >
{{ t('admin.groups.accountsCount', { count: value || 0 }) }} {{ t('admin.proxies.latencyFailed') }}
</span>
<span
v-else-if="typeof row.latency_ms === 'number'"
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
>
{{ row.latency_ms }}ms
</span>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.accounts.status.' + value) }}
</span> </span>
</template> </template>
...@@ -515,6 +579,63 @@ ...@@ -515,6 +579,63 @@
@confirm="confirmDelete" @confirm="confirmDelete"
@cancel="showDeleteDialog = false" @cancel="showDeleteDialog = false"
/> />
<!-- Batch Delete Confirmation Dialog -->
<ConfirmDialog
:show="showBatchDeleteDialog"
:title="t('admin.proxies.batchDelete')"
:message="t('admin.proxies.batchDeleteConfirm', { count: selectedCount })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmBatchDelete"
@cancel="showBatchDeleteDialog = false"
/>
<!-- Proxy Accounts Dialog -->
<BaseDialog
:show="showAccountsModal"
:title="t('admin.proxies.accountsTitle', { name: accountsProxy?.name || '' })"
width="normal"
@close="closeAccountsModal"
>
<div v-if="accountsLoading" class="flex items-center justify-center py-8 text-sm text-gray-500">
<Icon name="refresh" size="md" class="mr-2 animate-spin" />
{{ t('common.loading') }}
</div>
<div v-else-if="proxyAccounts.length === 0" class="py-6 text-center text-sm text-gray-500">
{{ t('admin.proxies.accountsEmpty') }}
</div>
<div v-else class="max-h-80 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
<tr>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountName') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.accounts.columns.platformType') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountNotes') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-for="account in proxyAccounts" :key="account.id">
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{{ account.name }}</td>
<td class="px-4 py-2">
<PlatformTypeBadge :platform="account.platform" :type="account.type" />
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
{{ account.notes || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="closeAccountsModal" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</AppLayout> </AppLayout>
</template> </template>
...@@ -523,7 +644,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' ...@@ -523,7 +644,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types' import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
...@@ -534,15 +655,18 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' ...@@ -534,15 +655,18 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true }, { key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true }, { key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false }, { key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true }, { key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true }, { key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false } { key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
]) ])
...@@ -592,11 +716,24 @@ const pagination = reactive({ ...@@ -592,11 +716,24 @@ const pagination = reactive({
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showEditModal = ref(false) const showEditModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
const showAccountsModal = ref(false)
const submitting = ref(false) const submitting = ref(false)
const testingProxyIds = ref<Set<number>>(new Set()) const testingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)
const editingProxy = ref<Proxy | null>(null) const editingProxy = ref<Proxy | null>(null)
const deletingProxy = ref<Proxy | null>(null) const deletingProxy = ref<Proxy | null>(null)
const selectedCount = computed(() => selectedProxyIds.value.size)
const allVisibleSelected = computed(() => {
if (proxies.value.length === 0) return false
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
})
// Batch import state // Batch import state
const createMode = ref<'standard' | 'batch'>('standard') const createMode = ref<'standard' | 'batch'>('standard')
const batchInput = ref('') const batchInput = ref('')
...@@ -641,6 +778,30 @@ const isAbortError = (error: unknown) => { ...@@ -641,6 +778,30 @@ const isAbortError = (error: unknown) => {
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED' return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
} }
const toggleSelectRow = (id: number, event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
if (target.checked) {
next.add(id)
} else {
next.delete(id)
}
selectedProxyIds.value = next
}
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
for (const proxy of proxies.value) {
if (target.checked) {
next.add(proxy.id)
} else {
next.delete(proxy.id)
}
}
selectedProxyIds.value = next
}
const loadProxies = async () => { const loadProxies = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
...@@ -895,35 +1056,151 @@ const handleUpdateProxy = async () => { ...@@ -895,35 +1056,151 @@ const handleUpdateProxy = async () => {
} }
} }
const applyLatencyResult = (
proxyId: number,
result: { success: boolean; latency_ms?: number; message?: string }
) => {
const target = proxies.value.find((proxy) => proxy.id === proxyId)
if (!target) return
if (result.success) {
target.latency_status = 'success'
target.latency_ms = result.latency_ms
} else {
target.latency_status = 'failed'
target.latency_ms = undefined
}
target.latency_message = result.message
}
const startTestingProxy = (proxyId: number) => {
testingProxyIds.value = new Set([...testingProxyIds.value, proxyId])
}
const stopTestingProxy = (proxyId: number) => {
const next = new Set(testingProxyIds.value)
next.delete(proxyId)
testingProxyIds.value = next
}
const runProxyTest = async (proxyId: number, notify: boolean) => {
startTestingProxy(proxyId)
try {
const result = await adminAPI.proxies.testProxy(proxyId)
applyLatencyResult(proxyId, result)
if (notify) {
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
}
}
return result
} catch (error: any) {
const message = error.response?.data?.detail || t('admin.proxies.failedToTest')
applyLatencyResult(proxyId, { success: false, message })
if (notify) {
appStore.showError(message)
}
console.error('Error testing proxy:', error)
return null
} finally {
stopTestingProxy(proxyId)
}
}
const handleTestConnection = async (proxy: Proxy) => { const handleTestConnection = async (proxy: Proxy) => {
// Create new Set to trigger reactivity await runProxyTest(proxy.id, true)
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id]) }
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
const pageSize = 200
const result: Proxy[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const response = await adminAPI.proxies.list(
page,
pageSize,
{
protocol: filters.protocol || undefined,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
result.push(...response.items)
totalPages = response.pages || 1
page++
}
return result
}
const runBatchProxyTests = async (ids: number[]) => {
if (ids.length === 0) return
const concurrency = 5
let index = 0
const worker = async () => {
while (index < ids.length) {
const current = ids[index]
index++
await runProxyTest(current, false)
}
}
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
await Promise.all(workers)
}
const handleBatchTest = async () => {
if (batchTesting.value) return
batchTesting.value = true
try { try {
const result = await adminAPI.proxies.testProxy(proxy.id) let ids: number[] = []
if (result.success) { if (selectedCount.value > 0) {
const message = result.latency_ms ids = Array.from(selectedProxyIds.value)
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else { } else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed')) const allProxies = await fetchAllProxiesForBatch()
ids = allProxies.map((proxy) => proxy.id)
} }
if (ids.length === 0) {
appStore.showInfo(t('admin.proxies.batchTestEmpty'))
return
}
await runBatchProxyTests(ids)
appStore.showSuccess(t('admin.proxies.batchTestDone', { count: ids.length }))
loadProxies()
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest')) appStore.showError(error.response?.data?.detail || t('admin.proxies.batchTestFailed'))
console.error('Error testing proxy:', error) console.error('Error batch testing proxies:', error)
} finally { } finally {
// Create new Set without this proxy id to trigger reactivity batchTesting.value = false
const newSet = new Set(testingProxyIds.value)
newSet.delete(proxy.id)
testingProxyIds.value = newSet
} }
} }
const handleDelete = (proxy: Proxy) => { const handleDelete = (proxy: Proxy) => {
if ((proxy.account_count || 0) > 0) {
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
return
}
deletingProxy.value = proxy deletingProxy.value = proxy
showDeleteDialog.value = true showDeleteDialog.value = true
} }
const openBatchDelete = () => {
if (selectedCount.value === 0) {
return
}
showBatchDeleteDialog.value = true
}
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deletingProxy.value) return if (!deletingProxy.value) return
...@@ -931,6 +1208,11 @@ const confirmDelete = async () => { ...@@ -931,6 +1208,11 @@ const confirmDelete = async () => {
await adminAPI.proxies.delete(deletingProxy.value.id) await adminAPI.proxies.delete(deletingProxy.value.id)
appStore.showSuccess(t('admin.proxies.proxyDeleted')) appStore.showSuccess(t('admin.proxies.proxyDeleted'))
showDeleteDialog.value = false showDeleteDialog.value = false
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
const next = new Set(selectedProxyIds.value)
next.delete(deletingProxy.value.id)
selectedProxyIds.value = next
}
deletingProxy.value = null deletingProxy.value = null
loadProxies() loadProxies()
} catch (error: any) { } catch (error: any) {
...@@ -939,6 +1221,55 @@ const confirmDelete = async () => { ...@@ -939,6 +1221,55 @@ const confirmDelete = async () => {
} }
} }
const confirmBatchDelete = async () => {
const ids = Array.from(selectedProxyIds.value)
if (ids.length === 0) {
showBatchDeleteDialog.value = false
return
}
try {
const result = await adminAPI.proxies.batchDelete(ids)
const deleted = result.deleted_ids?.length || 0
const skipped = result.skipped?.length || 0
if (deleted > 0) {
appStore.showSuccess(t('admin.proxies.batchDeleteDone', { deleted, skipped }))
} else if (skipped > 0) {
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
}
selectedProxyIds.value = new Set()
showBatchDeleteDialog.value = false
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchDeleteFailed'))
console.error('Error batch deleting proxies:', error)
}
}
const openAccountsModal = async (proxy: Proxy) => {
accountsProxy.value = proxy
proxyAccounts.value = []
accountsLoading.value = true
showAccountsModal.value = true
try {
proxyAccounts.value = await adminAPI.proxies.getProxyAccounts(proxy.id)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.accountsFailed'))
console.error('Error loading proxy accounts:', error)
} finally {
accountsLoading.value = false
}
}
const closeAccountsModal = () => {
showAccountsModal.value = false
accountsProxy.value = null
proxyAccounts.value = []
}
onMounted(() => { onMounted(() => {
loadProxies() loadProxies()
}) })
......
...@@ -44,8 +44,14 @@ let abortController: AbortController | null = null; let exportAbortController: A ...@@ -44,8 +44,14 @@ let abortController: AbortController | null = null; let exportAbortController: A
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }]) const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
const formatLD = (d: Date) => d.toISOString().split('T')[0] // Use local timezone to avoid UTC timezone issues
const now = new Date(); const weekAgo = new Date(Date.now() - 6 * 86400000) const formatLD = (d: Date) => {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now)) const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value }) const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: 20, total: 0 }) const pagination = reactive({ page: 1, page_size: 20, total: 0 })
...@@ -61,8 +67,8 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi ...@@ -61,8 +67,8 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
const loadChartData = async () => { const loadChartData = async () => {
chartsLoading.value = true chartsLoading.value = true
try { try {
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id } const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream }
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })]) const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || [] trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false } } catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
} }
...@@ -94,7 +100,7 @@ const exportToExcel = async () => { ...@@ -94,7 +100,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'), t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
t('admin.usage.inputCost'), t('admin.usage.outputCost'), t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'), t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
t('usage.firstToken'), t('usage.duration'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress') t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
] ]
...@@ -115,8 +121,10 @@ const exportToExcel = async () => { ...@@ -115,8 +121,10 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00', log.rate_multiplier?.toFixed(2) || '1.00',
(log.account_rate_multiplier ?? 1).toFixed(2),
log.total_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms,
log.request_id || '', log.request_id || '',
......
...@@ -3,11 +3,11 @@ ...@@ -3,11 +3,11 @@
<TablePageLayout> <TablePageLayout>
<!-- Single Row: Search, Filters, and Actions --> <!-- Single Row: Search, Filters, and Actions -->
<template #filters> <template #filters>
<div class="flex w-full flex-wrap-reverse items-center justify-between gap-4"> <div class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap-reverse md:items-center md:justify-between md:gap-4">
<!-- Left: Search + Active Filters --> <!-- Left: Search + Active Filters -->
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3"> <div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3 md:order-1">
<!-- Search Box --> <!-- Search Box -->
<div class="relative w-full sm:w-64"> <div class="relative w-full md:w-64">
<Icon <Icon
name="search" name="search"
size="md" size="md"
...@@ -100,109 +100,119 @@ ...@@ -100,109 +100,119 @@
</div> </div>
<!-- Right: Actions and Settings --> <!-- Right: Actions and Settings -->
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3"> <div class="flex w-full items-center justify-between gap-2 md:order-2 md:ml-auto md:max-w-full md:flex-wrap md:justify-end md:gap-3">
<!-- Refresh Button --> <!-- Mobile: Secondary buttons (icon only) -->
<button <div class="flex items-center gap-2 md:contents">
@click="loadUsers" <!-- Refresh Button -->
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<!-- Filter Settings Dropdown -->
<div class="relative" ref="filterDropdownRef">
<button <button
@click="showFilterDropdown = !showFilterDropdown" @click="loadUsers"
class="btn btn-secondary" :disabled="loading"
class="btn btn-secondary px-2 md:px-3"
:title="t('common.refresh')"
> >
<Icon name="filter" size="sm" class="mr-1.5" /> <Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
{{ t('admin.users.filterSettings') }}
</button> </button>
<!-- Dropdown menu --> <!-- Filter Settings Dropdown -->
<div <div class="relative" ref="filterDropdownRef">
v-if="showFilterDropdown"
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<!-- Built-in filters -->
<button <button
v-for="filter in builtInFilters" @click="showFilterDropdown = !showFilterDropdown"
:key="filter.key" class="btn btn-secondary px-2 md:px-3"
@click="toggleBuiltInFilter(filter.key)" :title="t('admin.users.filterSettings')"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
> >
<span>{{ filter.name }}</span> <Icon name="filter" size="sm" class="md:mr-1.5" />
<Icon <span class="hidden md:inline">{{ t('admin.users.filterSettings') }}</span>
v-if="visibleFilters.has(filter.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button> </button>
<!-- Divider if custom attributes exist --> <!-- Dropdown menu -->
<div <div
v-if="filterableAttributes.length > 0" v-if="showFilterDropdown"
class="my-1 border-t border-gray-100 dark:border-dark-700" class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
></div> >
<!-- Custom attribute filters --> <!-- Built-in filters -->
<button
v-for="filter in builtInFilters"
:key="filter.key"
@click="toggleBuiltInFilter(filter.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ filter.name }}</span>
<Icon
v-if="visibleFilters.has(filter.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
<!-- Divider if custom attributes exist -->
<div
v-if="filterableAttributes.length > 0"
class="my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Custom attribute filters -->
<button
v-for="attr in filterableAttributes"
:key="attr.id"
@click="toggleAttributeFilter(attr)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ attr.name }}</span>
<Icon
v-if="visibleFilters.has(`attr_${attr.id}`)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button <button
v-for="attr in filterableAttributes" @click="showColumnDropdown = !showColumnDropdown"
:key="attr.id" class="btn btn-secondary px-2 md:px-3"
@click="toggleAttributeFilter(attr)" :title="t('admin.users.columnSettings')"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
> >
<span>{{ attr.name }}</span> <svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<Icon <path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
v-if="visibleFilters.has(`attr_${attr.id}`)" </svg>
name="check" <span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button> </button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div> </div>
</div> <!-- Attributes Config Button -->
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button <button
@click="showColumnDropdown = !showColumnDropdown" @click="showAttributesModal = true"
class="btn btn-secondary" class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.attributes.configButton')"
> >
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <Icon name="cog" size="sm" class="md:mr-1.5" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" /> <span class="hidden md:inline">{{ t('admin.users.attributes.configButton') }}</span>
</svg>
{{ t('admin.users.columnSettings') }}
</button> </button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div> </div>
<!-- Attributes Config Button -->
<button @click="showAttributesModal = true" class="btn btn-secondary"> <!-- Create User Button (full width on mobile, auto width on desktop) -->
<Icon name="cog" size="sm" class="mr-1.5" /> <button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
{{ t('admin.users.attributes.configButton') }}
</button>
<!-- Create User Button -->
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.users.createUser') }} {{ t('admin.users.createUser') }}
</button> </button>
...@@ -362,8 +372,7 @@ ...@@ -362,8 +372,7 @@
<!-- More Actions Menu Trigger --> <!-- More Actions Menu Trigger -->
<button <button
:ref="(el) => setActionButtonRef(row.id, el)" @click="openActionMenu(row, $event)"
@click="openActionMenu(row)"
class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white" class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
> >
...@@ -475,7 +484,7 @@ ...@@ -475,7 +484,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
...@@ -735,42 +744,56 @@ let abortController: AbortController | null = null ...@@ -735,42 +744,56 @@ let abortController: AbortController | null = null
// Action Menu State // Action Menu State
const activeMenuId = ref<number | null>(null) const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null) const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(userId, el)
} else {
actionButtonRefs.value.delete(userId)
}
}
const openActionMenu = (user: User) => { const openActionMenu = (user: User, e: MouseEvent) => {
if (activeMenuId.value === user.id) { if (activeMenuId.value === user.id) {
closeActionMenu() closeActionMenu()
} else { } else {
const buttonEl = actionButtonRefs.value.get(user.id) const target = e.currentTarget as HTMLElement
if (buttonEl) { if (!target) {
const rect = buttonEl.getBoundingClientRect() closeActionMenu()
const menuWidth = 192 return
const menuHeight = 240 }
const padding = 8
const viewportWidth = window.innerWidth const rect = target.getBoundingClientRect()
const viewportHeight = window.innerHeight const menuWidth = 200
const left = Math.min( const menuHeight = 240
Math.max(rect.right - menuWidth, padding), const padding = 8
Math.max(viewportWidth - menuWidth - padding, padding) const viewportWidth = window.innerWidth
) const viewportHeight = window.innerHeight
let top = rect.bottom + 4
let left, top
if (viewportWidth < 768) {
// 居中显示,水平位置
left = Math.max(padding, Math.min(
rect.left + rect.width / 2 - menuWidth / 2,
viewportWidth - menuWidth - padding
))
// 优先显示在按钮下方
top = rect.bottom + 4
// 如果下方空间不够,显示在上方
if (top + menuHeight > viewportHeight - padding) { if (top + menuHeight > viewportHeight - padding) {
top = Math.max(rect.top - menuHeight - 4, padding) top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
} }
// Position menu near the trigger, clamped to viewport } else {
menuPosition.value = { left = Math.max(padding, Math.min(
top, e.clientX - menuWidth,
left viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
} }
} }
menuPosition.value = { top, left }
activeMenuId.value = user.id activeMenuId.value = user.id
} }
} }
...@@ -1054,16 +1077,24 @@ const closeBalanceModal = () => { ...@@ -1054,16 +1077,24 @@ const closeBalanceModal = () => {
showBalanceModal.value = false showBalanceModal.value = false
balanceUser.value = null balanceUser.value = null
} }
// 滚动时关闭菜单
const handleScroll = () => {
closeActionMenu()
}
onMounted(async () => { onMounted(async () => {
await loadAttributeDefinitions() await loadAttributeDefinitions()
loadSavedFilters() loadSavedFilters()
loadSavedColumns() loadSavedColumns()
loadUsers() loadUsers()
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', handleScroll, true)
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
abortController?.abort() abortController?.abort()
}) })
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" /> <OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" :fullscreen="isFullscreen" />
<OpsDashboardHeader <OpsDashboardHeader
v-else-if="opsEnabled" v-else-if="opsEnabled"
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" /> <OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="errorDetailsType" />
<OpsRequestDetailsModal <OpsRequestDetailsModal
v-model="showRequestDetails" v-model="showRequestDetails"
...@@ -169,7 +169,13 @@ const QUERY_KEYS = { ...@@ -169,7 +169,13 @@ const QUERY_KEYS = {
platform: 'platform', platform: 'platform',
groupId: 'group_id', groupId: 'group_id',
queryMode: 'mode', queryMode: 'mode',
fullscreen: 'fullscreen' fullscreen: 'fullscreen',
// Deep links
openErrorDetails: 'open_error_details',
errorType: 'error_type',
alertRuleId: 'alert_rule_id',
openAlertRules: 'open_alert_rules'
} as const } as const
const isApplyingRouteQuery = ref(false) const isApplyingRouteQuery = ref(false)
...@@ -249,6 +255,24 @@ const applyRouteQueryToState = () => { ...@@ -249,6 +255,24 @@ const applyRouteQueryToState = () => {
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto' const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto' queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
} }
// Deep links
const openRules = readQueryString(QUERY_KEYS.openAlertRules)
if (openRules === '1' || openRules === 'true') {
showAlertRulesCard.value = true
}
const ruleID = readQueryNumber(QUERY_KEYS.alertRuleId)
if (typeof ruleID === 'number' && ruleID > 0) {
showAlertRulesCard.value = true
}
const openErr = readQueryString(QUERY_KEYS.openErrorDetails)
if (openErr === '1' || openErr === 'true') {
const typ = readQueryString(QUERY_KEYS.errorType)
errorDetailsType.value = typ === 'upstream' ? 'upstream' : 'request'
showErrorDetails.value = true
}
} }
applyRouteQueryToState() applyRouteQueryToState()
...@@ -376,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) { ...@@ -376,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) } requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showErrorModal.value = false
showRequestDetails.value = true showRequestDetails.value = true
} }
function openErrorDetails(kind: 'request' | 'upstream') { function openErrorDetails(kind: 'request' | 'upstream') {
errorDetailsType.value = kind errorDetailsType.value = kind
// Ensure only one modal visible at a time.
showRequestDetails.value = false
showErrorModal.value = false
showErrorDetails.value = true showErrorDetails.value = true
} }
...@@ -422,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) { ...@@ -422,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) {
function openError(id: number) { function openError(id: number) {
selectedErrorId.value = id selectedErrorId.value = id
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showRequestDetails.value = false
showErrorModal.value = true showErrorModal.value = true
} }
......
...@@ -3,42 +3,326 @@ import { computed, onMounted, ref, watch } from 'vue' ...@@ -3,42 +3,326 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import { opsAPI } from '@/api/admin/ops' import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { opsAPI, type AlertEventsQuery } from '@/api/admin/ops'
import type { AlertEvent } from '../types' import type { AlertEvent } from '../types'
import { formatDateTime } from '../utils/opsFormatters' import { formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const PAGE_SIZE = 10
const loading = ref(false) const loading = ref(false)
const loadingMore = ref(false)
const events = ref<AlertEvent[]>([]) const events = ref<AlertEvent[]>([])
const hasMore = ref(true)
// Detail modal
const showDetail = ref(false)
const selected = ref<AlertEvent | null>(null)
const detailLoading = ref(false)
const detailActionLoading = ref(false)
const historyLoading = ref(false)
const history = ref<AlertEvent[]>([])
const historyRange = ref('7d')
const historyRangeOptions = computed(() => [
{ value: '7d', label: t('admin.ops.timeRange.7d') },
{ value: '30d', label: t('admin.ops.timeRange.30d') }
])
const silenceDuration = ref('1h')
const silenceDurationOptions = computed(() => [
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') },
{ value: '7d', label: t('admin.ops.timeRange.7d') }
])
// Filters
const timeRange = ref('24h')
const timeRangeOptions = computed(() => [
{ value: '5m', label: t('admin.ops.timeRange.5m') },
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '6h', label: t('admin.ops.timeRange.6h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') },
{ value: '7d', label: t('admin.ops.timeRange.7d') },
{ value: '30d', label: t('admin.ops.timeRange.30d') }
])
const limit = ref(100) const severity = ref<string>('')
const limitOptions = computed(() => [ const severityOptions = computed(() => [
{ value: 50, label: '50' }, { value: '', label: t('common.all') },
{ value: 100, label: '100' }, { value: 'P0', label: 'P0' },
{ value: 200, label: '200' } { value: 'P1', label: 'P1' },
{ value: 'P2', label: 'P2' },
{ value: 'P3', label: 'P3' }
]) ])
async function load() { const status = ref<string>('')
const statusOptions = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'firing', label: t('admin.ops.alertEvents.status.firing') },
{ value: 'resolved', label: t('admin.ops.alertEvents.status.resolved') },
{ value: 'manual_resolved', label: t('admin.ops.alertEvents.status.manualResolved') }
])
const emailSent = ref<string>('')
const emailSentOptions = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'true', label: t('admin.ops.alertEvents.table.emailSent') },
{ value: 'false', label: t('admin.ops.alertEvents.table.emailIgnored') }
])
function buildQuery(overrides: Partial<AlertEventsQuery> = {}): AlertEventsQuery {
const q: AlertEventsQuery = {
limit: PAGE_SIZE,
time_range: timeRange.value
}
if (severity.value) q.severity = severity.value
if (status.value) q.status = status.value
if (emailSent.value === 'true') q.email_sent = true
if (emailSent.value === 'false') q.email_sent = false
return { ...q, ...overrides }
}
async function loadFirstPage() {
loading.value = true loading.value = true
try { try {
events.value = await opsAPI.listAlertEvents(limit.value) const data = await opsAPI.listAlertEvents(buildQuery())
events.value = data
hasMore.value = data.length === PAGE_SIZE
} catch (err: any) { } catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to load alert events', err) console.error('[OpsAlertEventsCard] Failed to load alert events', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed')) appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
events.value = [] events.value = []
hasMore.value = false
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadMore() {
if (loadingMore.value || loading.value) return
if (!hasMore.value) return
const last = events.value[events.value.length - 1]
if (!last) return
loadingMore.value = true
try {
const data = await opsAPI.listAlertEvents(
buildQuery({ before_fired_at: last.fired_at || last.created_at, before_id: last.id })
)
if (!data.length) {
hasMore.value = false
return
}
events.value = [...events.value, ...data]
if (data.length < PAGE_SIZE) hasMore.value = false
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to load more alert events', err)
hasMore.value = false
} finally {
loadingMore.value = false
}
}
function onScroll(e: Event) {
const el = e.target as HTMLElement | null
if (!el) return
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 120
if (nearBottom) loadMore()
}
function getDimensionString(event: AlertEvent | null | undefined, key: string): string {
const v = event?.dimensions?.[key]
if (v == null) return ''
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
return ''
}
function formatDurationMs(ms: number): string {
const safe = Math.max(0, Math.floor(ms))
const sec = Math.floor(safe / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.floor(hr / 24)
return `${day}d`
}
function formatDurationLabel(event: AlertEvent): string {
const firedAt = new Date(event.fired_at || event.created_at)
if (Number.isNaN(firedAt.getTime())) return '-'
const resolvedAtStr = event.resolved_at || null
const status = String(event.status || '').trim().toLowerCase()
if (resolvedAtStr) {
const resolvedAt = new Date(resolvedAtStr)
if (!Number.isNaN(resolvedAt.getTime())) {
const ms = resolvedAt.getTime() - firedAt.getTime()
const prefix = status === 'manual_resolved'
? t('admin.ops.alertEvents.status.manualResolved')
: t('admin.ops.alertEvents.status.resolved')
return `${prefix} ${formatDurationMs(ms)}`
}
}
const now = Date.now()
const ms = now - firedAt.getTime()
return `${t('admin.ops.alertEvents.status.firing')} ${formatDurationMs(ms)}`
}
function formatDimensionsSummary(event: AlertEvent): string {
const parts: string[] = []
const platform = getDimensionString(event, 'platform')
if (platform) parts.push(`platform=${platform}`)
const groupId = event.dimensions?.group_id
if (groupId != null && groupId !== '') parts.push(`group_id=${String(groupId)}`)
const region = getDimensionString(event, 'region')
if (region) parts.push(`region=${region}`)
return parts.length ? parts.join(' ') : '-'
}
function closeDetail() {
showDetail.value = false
selected.value = null
history.value = []
}
async function openDetail(row: AlertEvent) {
showDetail.value = true
selected.value = row
detailLoading.value = true
historyLoading.value = true
try {
const detail = await opsAPI.getAlertEvent(row.id)
selected.value = detail
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to load alert detail', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.loadFailed'))
} finally {
detailLoading.value = false
}
await loadHistory()
}
async function loadHistory() {
const ev = selected.value
if (!ev) {
history.value = []
historyLoading.value = false
return
}
historyLoading.value = true
try {
const platform = getDimensionString(ev, 'platform')
const groupIdRaw = ev.dimensions?.group_id
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : undefined
const items = await opsAPI.listAlertEvents({
limit: 20,
time_range: historyRange.value,
platform: platform || undefined,
group_id: groupId,
status: ''
})
// Best-effort: narrow to same rule_id + dimensions
history.value = items.filter((it) => {
if (it.rule_id !== ev.rule_id) return false
const p1 = getDimensionString(it, 'platform')
const p2 = getDimensionString(ev, 'platform')
if ((p1 || '') !== (p2 || '')) return false
const g1 = it.dimensions?.group_id
const g2 = ev.dimensions?.group_id
return (g1 ?? null) === (g2 ?? null)
})
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to load alert history', err)
history.value = []
} finally {
historyLoading.value = false
}
}
function durationToUntilRFC3339(duration: string): string {
const now = Date.now()
if (duration === '1h') return new Date(now + 60 * 60 * 1000).toISOString()
if (duration === '24h') return new Date(now + 24 * 60 * 60 * 1000).toISOString()
if (duration === '7d') return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
return new Date(now + 60 * 60 * 1000).toISOString()
}
async function silenceAlert() {
const ev = selected.value
if (!ev) return
if (detailActionLoading.value) return
detailActionLoading.value = true
try {
const platform = getDimensionString(ev, 'platform')
const groupIdRaw = ev.dimensions?.group_id
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : null
const region = getDimensionString(ev, 'region') || null
await opsAPI.createAlertSilence({
rule_id: ev.rule_id,
platform: platform || '',
group_id: groupId ?? undefined,
region: region ?? undefined,
until: durationToUntilRFC3339(silenceDuration.value),
reason: `silence from UI (${silenceDuration.value})`
})
appStore.showSuccess(t('admin.ops.alertEvents.detail.silenceSuccess'))
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to silence alert', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.silenceFailed'))
} finally {
detailActionLoading.value = false
}
}
async function manualResolve() {
if (!selected.value) return
if (detailActionLoading.value) return
detailActionLoading.value = true
try {
await opsAPI.updateAlertEventStatus(selected.value.id, 'manual_resolved')
appStore.showSuccess(t('admin.ops.alertEvents.detail.manualResolvedSuccess'))
// Refresh detail + first page to reflect new status
const detail = await opsAPI.getAlertEvent(selected.value.id)
selected.value = detail
await loadFirstPage()
await loadHistory()
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to resolve alert', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.manualResolvedFailed'))
} finally {
detailActionLoading.value = false
}
}
onMounted(() => { onMounted(() => {
load() loadFirstPage()
}) })
watch(limit, () => { watch([timeRange, severity, status, emailSent], () => {
load() events.value = []
hasMore.value = true
loadFirstPage()
})
watch(historyRange, () => {
if (showDetail.value) loadHistory()
}) })
function severityBadgeClass(severity: string | undefined): string { function severityBadgeClass(severity: string | undefined): string {
...@@ -54,9 +338,19 @@ function statusBadgeClass(status: string | undefined): string { ...@@ -54,9 +338,19 @@ function statusBadgeClass(status: string | undefined): string {
const s = String(status || '').trim().toLowerCase() const s = String(status || '').trim().toLowerCase()
if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30' if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30'
if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30'
if (s === 'manual_resolved') return 'bg-slate-50 text-slate-700 ring-slate-600/20 dark:bg-slate-900/30 dark:text-slate-300 dark:ring-slate-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30' return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'
} }
function formatStatusLabel(status: string | undefined): string {
const s = String(status || '').trim().toLowerCase()
if (!s) return '-'
if (s === 'firing') return t('admin.ops.alertEvents.status.firing')
if (s === 'resolved') return t('admin.ops.alertEvents.status.resolved')
if (s === 'manual_resolved') return t('admin.ops.alertEvents.status.manualResolved')
return s.toUpperCase()
}
const empty = computed(() => events.value.length === 0 && !loading.value) const empty = computed(() => events.value.length === 0 && !loading.value)
</script> </script>
...@@ -69,11 +363,14 @@ const empty = computed(() => events.value.length === 0 && !loading.value) ...@@ -69,11 +363,14 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select :model-value="limit" :options="limitOptions" class="w-[88px]" @change="limit = Number($event || 100)" /> <Select :model-value="timeRange" :options="timeRangeOptions" class="w-[120px]" @change="timeRange = String($event || '24h')" />
<Select :model-value="severity" :options="severityOptions" class="w-[88px]" @change="severity = String($event || '')" />
<Select :model-value="status" :options="statusOptions" class="w-[110px]" @change="status = String($event || '')" />
<Select :model-value="emailSent" :options="emailSentOptions" class="w-[110px]" @change="emailSent = String($event || '')" />
<button <button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading" :disabled="loading"
@click="load" @click="loadFirstPage"
> >
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
...@@ -96,7 +393,7 @@ const empty = computed(() => events.value.length === 0 && !loading.value) ...@@ -96,7 +393,7 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
</div> </div>
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700"> <div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div class="max-h-[600px] overflow-y-auto"> <div class="max-h-[600px] overflow-y-auto" @scroll="onScroll">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900"> <thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
<tr> <tr>
...@@ -104,16 +401,22 @@ const empty = computed(() => events.value.length === 0 && !loading.value) ...@@ -104,16 +401,22 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
{{ t('admin.ops.alertEvents.table.time') }} {{ t('admin.ops.alertEvents.table.time') }}
</th> </th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.status') }} {{ t('admin.ops.alertEvents.table.severity') }}
</th> </th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.severity') }} {{ t('admin.ops.alertEvents.table.platform') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.ruleId') }}
</th> </th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.title') }} {{ t('admin.ops.alertEvents.table.title') }}
</th> </th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.metric') }} {{ t('admin.ops.alertEvents.table.duration') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.dimensions') }}
</th> </th>
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.email') }} {{ t('admin.ops.alertEvents.table.email') }}
...@@ -121,45 +424,225 @@ const empty = computed(() => events.value.length === 0 && !loading.value) ...@@ -121,45 +424,225 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800"> <tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="row in events" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50"> <tr
v-for="row in events"
:key="row.id"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/50"
@click="openDetail(row)"
:title="row.title || ''"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300"> <td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ formatDateTime(row.fired_at || row.created_at) }} {{ formatDateTime(row.fired_at || row.created_at) }}
</td> </td>
<td class="whitespace-nowrap px-4 py-3"> <td class="whitespace-nowrap px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)"> <div class="flex items-center gap-2">
{{ String(row.status || '-').toUpperCase() }} <span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
</span> {{ row.severity || '-' }}
</span>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
{{ formatStatusLabel(row.status) }}
</span>
</div>
</td> </td>
<td class="whitespace-nowrap px-4 py-3"> <td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))"> {{ getDimensionString(row, 'platform') || '-' }}
{{ row.severity || '-' }} </td>
</span> <td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
<span class="font-mono">#{{ row.rule_id }}</span>
</td> </td>
<td class="min-w-[280px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200"> <td class="min-w-[260px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
<div class="font-semibold">{{ row.title || '-' }}</div> <div class="font-semibold truncate max-w-[360px]">{{ row.title || '-' }}</div>
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400"> <div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
{{ row.description }} {{ row.description }}
</div> </div>
</td> </td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300"> <td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
<span v-if="typeof row.metric_value === 'number' && typeof row.threshold_value === 'number'"> {{ formatDurationLabel(row) }}
{{ row.metric_value.toFixed(2) }} / {{ row.threshold_value.toFixed(2) }} </td>
</span> <td class="whitespace-nowrap px-4 py-3 text-[11px] text-gray-500 dark:text-gray-400">
<span v-else>-</span> {{ formatDimensionsSummary(row) }}
</td> </td>
<td class="whitespace-nowrap px-4 py-3 text-right text-xs"> <td class="whitespace-nowrap px-4 py-3 text-right text-xs">
<span <span
class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" class="inline-flex items-center justify-end gap-1.5"
:class="row.email_sent ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' : 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'" :title="row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored')"
> >
{{ row.email_sent ? t('common.enabled') : t('common.disabled') }} <Icon
v-if="row.email_sent"
name="checkCircle"
size="sm"
class="text-green-600 dark:text-green-400"
/>
<Icon
v-else
name="ban"
size="sm"
class="text-gray-400 dark:text-gray-500"
/>
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">
{{ row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored') }}
</span>
</span> </span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-if="loadingMore" class="flex items-center justify-center gap-2 py-3 text-xs text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('admin.ops.alertEvents.loading') }}
</div>
<div v-else-if="!hasMore && events.length > 0" class="py-3 text-center text-xs text-gray-400">
-
</div>
</div> </div>
</div> </div>
<BaseDialog
:show="showDetail"
:title="t('admin.ops.alertEvents.detail.title')"
width="wide"
:close-on-click-outside="true"
@close="closeDetail"
>
<div v-if="detailLoading" class="flex items-center justify-center py-10 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.detail.loading') }}
</div>
<div v-else-if="!selected" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.detail.empty') }}
</div>
<div v-else class="space-y-5">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(selected.severity || ''))">
{{ selected.severity || '-' }}
</span>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(selected.status)">
{{ formatStatusLabel(selected.status) }}
</span>
</div>
<div class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{{ selected.title || '-' }}
</div>
<div v-if="selected.description" class="mt-1 whitespace-pre-wrap text-xs text-gray-600 dark:text-gray-300">
{{ selected.description }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex items-center gap-2 rounded-lg bg-white px-2 py-1 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">{{ t('admin.ops.alertEvents.detail.silence') }}</span>
<Select
:model-value="silenceDuration"
:options="silenceDurationOptions"
class="w-[110px]"
@change="silenceDuration = String($event || '1h')"
/>
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="silenceAlert">
<Icon name="ban" size="sm" />
{{ t('common.apply') }}
</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="manualResolve">
<Icon name="checkCircle" size="sm" />
{{ t('admin.ops.alertEvents.detail.manualResolve') }}
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.firedAt') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ formatDateTime(selected.fired_at || selected.created_at) }}</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.resolvedAt') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ selected.resolved_at ? formatDateTime(selected.resolved_at) : '-' }}</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.ruleId') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<div class="font-mono text-sm font-bold text-gray-900 dark:text-white">#{{ selected.rule_id }}</div>
<a
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
:href="`/admin/ops?open_alert_rules=1&alert_rule_id=${selected.rule_id}`"
>
<Icon name="externalLink" size="xs" />
{{ t('admin.ops.alertEvents.detail.viewRule') }}
</a>
<a
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
:href="`/admin/ops?platform=${encodeURIComponent(getDimensionString(selected,'platform')||'')}&group_id=${selected.dimensions?.group_id || ''}&error_type=request&open_error_details=1`"
>
<Icon name="externalLink" size="xs" />
{{ t('admin.ops.alertEvents.detail.viewLogs') }}
</a>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.dimensions') }}</div>
<div class="mt-1 text-sm text-gray-900 dark:text-white">
<div v-if="getDimensionString(selected, 'platform')">platform={{ getDimensionString(selected, 'platform') }}</div>
<div v-if="selected.dimensions?.group_id">group_id={{ selected.dimensions.group_id }}</div>
<div v-if="getDimensionString(selected, 'region')">region={{ getDimensionString(selected, 'region') }}</div>
</div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.detail.historyTitle') }}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.detail.historyHint') }}</div>
</div>
<Select :model-value="historyRange" :options="historyRangeOptions" class="w-[140px]" @change="historyRange = String($event || '7d')" />
</div>
<div v-if="historyLoading" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.detail.historyLoading') }}
</div>
<div v-else-if="history.length === 0" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.detail.historyEmpty') }}
</div>
<div v-else class="overflow-hidden rounded-lg border border-gray-100 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-100 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.time') }}</th>
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.status') }}</th>
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.metric') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-for="it in history" :key="it.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">{{ formatDateTime(it.fired_at || it.created_at) }}</td>
<td class="px-3 py-2 text-xs">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(it.status)">
{{ formatStatusLabel(it.status) }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">
<span v-if="typeof it.metric_value === 'number' && typeof it.threshold_value === 'number'">
{{ it.metric_value.toFixed(2) }} / {{ it.threshold_value.toFixed(2) }}
</span>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</BaseDialog>
</div> </div>
</template> </template>
...@@ -140,24 +140,6 @@ const metricDefinitions = computed(() => { ...@@ -140,24 +140,6 @@ const metricDefinitions = computed(() => {
recommendedThreshold: 1, recommendedThreshold: 1,
unit: '%' unit: '%'
}, },
{
type: 'p95_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p95'),
description: t('admin.ops.alertRules.metricDescriptions.p95'),
recommendedOperator: '>',
recommendedThreshold: 1000,
unit: 'ms'
},
{
type: 'p99_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p99'),
description: t('admin.ops.alertRules.metricDescriptions.p99'),
recommendedOperator: '>',
recommendedThreshold: 2000,
unit: 'ms'
},
{ {
type: 'cpu_usage_percent', type: 'cpu_usage_percent',
group: 'system', group: 'system',
......
...@@ -169,8 +169,8 @@ const updatedAtLabel = computed(() => { ...@@ -169,8 +169,8 @@ const updatedAtLabel = computed(() => {
return props.lastUpdated.toLocaleTimeString() return props.lastUpdated.toLocaleTimeString()
}) })
// --- Color coding for latency/TTFT --- // --- Color coding for TTFT ---
function getLatencyColor(ms: number | null | undefined): string { function getTTFTColor(ms: number | null | undefined): string {
if (ms == null) return 'text-gray-900 dark:text-white' if (ms == null) return 'text-gray-900 dark:text-white'
if (ms < 500) return 'text-green-600 dark:text-green-400' if (ms < 500) return 'text-green-600 dark:text-green-400'
if (ms < 1000) return 'text-yellow-600 dark:text-yellow-400' if (ms < 1000) return 'text-yellow-600 dark:text-yellow-400'
...@@ -186,13 +186,6 @@ function isSLABelowThreshold(slaPercent: number | null): boolean { ...@@ -186,13 +186,6 @@ function isSLABelowThreshold(slaPercent: number | null): boolean {
return slaPercent < threshold return slaPercent < threshold
} }
function isLatencyAboveThreshold(latencyP99Ms: number | null): boolean {
if (latencyP99Ms == null) return false
const threshold = props.thresholds?.latency_p99_ms_max
if (threshold == null) return false
return latencyP99Ms > threshold
}
function isTTFTAboveThreshold(ttftP99Ms: number | null): boolean { function isTTFTAboveThreshold(ttftP99Ms: number | null): boolean {
if (ttftP99Ms == null) return false if (ttftP99Ms == null) return false
const threshold = props.thresholds?.ttft_p99_ms_max const threshold = props.thresholds?.ttft_p99_ms_max
...@@ -482,24 +475,6 @@ const diagnosisReport = computed<DiagnosisItem[]>(() => { ...@@ -482,24 +475,6 @@ const diagnosisReport = computed<DiagnosisItem[]>(() => {
} }
} }
// Latency diagnostics
const durationP99 = ov.duration?.p99_ms ?? 0
if (durationP99 > 2000) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.latencyCritical', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyCriticalImpact'),
action: t('admin.ops.diagnosis.latencyCriticalAction')
})
} else if (durationP99 > 1000) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.latencyHigh', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyHighImpact'),
action: t('admin.ops.diagnosis.latencyHighAction')
})
}
const ttftP99 = ov.ttft?.p99_ms ?? 0 const ttftP99 = ov.ttft?.p99_ms ?? 0
if (ttftP99 > 500) { if (ttftP99 > 500) {
report.push({ report.push({
...@@ -851,7 +826,7 @@ function handleToolbarRefresh() { ...@@ -851,7 +826,7 @@ function handleToolbarRefresh() {
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
<span>自动刷新: {{ props.autoRefreshCountdown }}s</span> <span>{{ t('admin.ops.settings.autoRefreshCountdown', { seconds: props.autoRefreshCountdown }) }}</span>
</span> </span>
</template> </template>
...@@ -1113,7 +1088,7 @@ function handleToolbarRefresh() { ...@@ -1113,7 +1088,7 @@ function handleToolbarRefresh() {
</div> </div>
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span> <span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span>
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">TPS</span> <span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">{{ t('admin.ops.tps') }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -1130,7 +1105,7 @@ function handleToolbarRefresh() { ...@@ -1130,7 +1105,7 @@ function handleToolbarRefresh() {
</div> </div>
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsPeakLabel }}</span> <span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsPeakLabel }}</span>
<span class="text-xs">TPS</span> <span class="text-xs">{{ t('admin.ops.tps') }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -1145,7 +1120,7 @@ function handleToolbarRefresh() { ...@@ -1145,7 +1120,7 @@ function handleToolbarRefresh() {
</div> </div>
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsAvgLabel }}</span> <span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsAvgLabel }}</span>
<span class="text-xs">TPS</span> <span class="text-xs">{{ t('admin.ops.tps') }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -1181,7 +1156,7 @@ function handleToolbarRefresh() { ...@@ -1181,7 +1156,7 @@ function handleToolbarRefresh() {
<!-- Right: 6 cards (3 cols x 2 rows) --> <!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3"> <div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Requests --> <!-- Card 1: Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 1;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span>
...@@ -1217,10 +1192,10 @@ function handleToolbarRefresh() { ...@@ -1217,10 +1192,10 @@ function handleToolbarRefresh() {
</div> </div>
<!-- Card 2: SLA --> <!-- Card 2: SLA -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 2;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.sla') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" />
<span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span> <span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
</div> </div>
...@@ -1247,8 +1222,8 @@ function handleToolbarRefresh() { ...@@ -1247,8 +1222,8 @@ function handleToolbarRefresh() {
</div> </div>
</div> </div>
<!-- Card 3: Latency (Duration) --> <!-- Card 4: Request Duration -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 4;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
...@@ -1264,42 +1239,42 @@ function handleToolbarRefresh() { ...@@ -1264,42 +1239,42 @@ function handleToolbarRefresh() {
</button> </button>
</div> </div>
<div class="mt-2 flex items-baseline gap-2"> <div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="isLatencyAboveThreshold(durationP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(durationP99Ms)"> <div class="text-3xl font-black text-gray-900 dark:text-white">
{{ durationP99Ms ?? '-' }} {{ durationP99Ms ?? '-' }}
</div> </div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span> <span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div> </div>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span> <span class="text-gray-500">{{ t('admin.ops.p95') }}</span>
<span class="font-bold" :class="getLatencyColor(durationP95Ms)">{{ durationP95Ms ?? '-' }}</span> <span class="font-bold text-gray-900 dark:text-white">{{ durationP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span> <span class="text-gray-500">{{ t('admin.ops.p90') }}</span>
<span class="font-bold" :class="getLatencyColor(durationP90Ms)">{{ durationP90Ms ?? '-' }}</span> <span class="font-bold text-gray-900 dark:text-white">{{ durationP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span> <span class="text-gray-500">{{ t('admin.ops.p50') }}</span>
<span class="font-bold" :class="getLatencyColor(durationP50Ms)">{{ durationP50Ms ?? '-' }}</span> <span class="font-bold text-gray-900 dark:text-white">{{ durationP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span> <span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}</span> <span class="font-bold text-gray-900 dark:text-white">{{ durationAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span> <span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(durationMaxMs)">{{ durationMaxMs ?? '-' }}</span> <span class="font-bold text-gray-900 dark:text-white">{{ durationMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Card 4: TTFT --> <!-- Card 5: TTFT -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 5;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span> <span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
...@@ -1309,48 +1284,48 @@ function handleToolbarRefresh() { ...@@ -1309,48 +1284,48 @@ function handleToolbarRefresh() {
v-if="!props.fullscreen" v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline" class="text-[10px] font-bold text-blue-500 hover:underline"
type="button" type="button"
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })" @click="openDetails({ title: t('admin.ops.ttftLabel'), sort: 'duration_desc' })"
> >
{{ t('admin.ops.requestDetails.details') }} {{ t('admin.ops.requestDetails.details') }}
</button> </button>
</div> </div>
<div class="mt-2 flex items-baseline gap-2"> <div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="isTTFTAboveThreshold(ttftP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(ttftP99Ms)"> <div class="text-3xl font-black" :class="isTTFTAboveThreshold(ttftP99Ms) ? 'text-red-600 dark:text-red-400' : getTTFTColor(ttftP99Ms)">
{{ ttftP99Ms ?? '-' }} {{ ttftP99Ms ?? '-' }}
</div> </div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span> <span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div> </div>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span> <span class="text-gray-500">{{ t('admin.ops.p95') }}</span>
<span class="font-bold" :class="getLatencyColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}</span> <span class="font-bold" :class="getTTFTColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span> <span class="text-gray-500">{{ t('admin.ops.p90') }}</span>
<span class="font-bold" :class="getLatencyColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}</span> <span class="font-bold" :class="getTTFTColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span> <span class="text-gray-500">{{ t('admin.ops.p50') }}</span>
<span class="font-bold" :class="getLatencyColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}</span> <span class="font-bold" :class="getTTFTColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span> <span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span> <span class="font-bold" :class="getTTFTColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap"> <div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span> <span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span> <span class="font-bold" :class="getTTFTColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span> <span class="text-gray-400">ms</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Card 5: Request Errors --> <!-- Card 3: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 3;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
...@@ -1376,7 +1351,7 @@ function handleToolbarRefresh() { ...@@ -1376,7 +1351,7 @@ function handleToolbarRefresh() {
</div> </div>
<!-- Card 6: Upstream Errors --> <!-- Card 6: Upstream Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 6;">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
...@@ -1423,7 +1398,7 @@ function handleToolbarRefresh() { ...@@ -1423,7 +1398,7 @@ function handleToolbarRefresh() {
<!-- MEM --> <!-- MEM -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.mem') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="memPercentClass"> <div class="mt-1 text-lg font-black" :class="memPercentClass">
...@@ -1441,7 +1416,7 @@ function handleToolbarRefresh() { ...@@ -1441,7 +1416,7 @@ function handleToolbarRefresh() {
<!-- DB --> <!-- DB -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.db') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="dbMiddleClass"> <div class="mt-1 text-lg font-black" :class="dbMiddleClass">
......
<script setup lang="ts">
interface Props {
fullscreen?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fullscreen: false
})
</script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header (matches OpsDashboardHeader + overview blocks) -->
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"> <div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div class="space-y-2"> <div class="space-y-2">
<div class="h-5 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-6 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-72 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div> <div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div> </div>
<div class="flex items-center gap-3"> <div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-3">
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[160px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[150px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div> <div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div> <div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
</div> </div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4"> <div class="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-12">
<div v-for="i in 4" :key="i" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30 lg:col-span-5">
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
<div class="mt-3 h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-28 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
<div class="space-y-4">
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="grid grid-cols-2 gap-3">
<div v-for="i in 4" :key="i" class="h-14 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-7">
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="i in 6" :key="i" class="h-20 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Charts --> <!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"> <div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div> <div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div> </div>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"> <div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-2', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div> <div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div> </div>
</div> </div>
<!-- Cards --> <!-- Row: Visual Analysis (baseline 3-up grid) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div <div
v-for="i in 3" v-for="i in 3"
:key="i" :key="i"
class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700" :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']"
> >
<div class="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-4 space-y-3"> <div class="mt-6 h-56 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div> </div>
<div class="h-3 w-1/2 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div> </div>
<div class="h-3 w-3/5 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<!-- Alert Events -->
<div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-2">
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<div class="mt-6 space-y-3">
<div v-for="i in 6" :key="i" class="flex items-center justify-between gap-4 rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="flex-1 space-y-2">
<div class="h-3 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="h-7 w-20 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
</div> </div>
</div> </div>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment