feat(ops): add usage_logs partition status to ops dashboard

Add partition management integration to the smart ops system:
- Backend: Add GetUsageLogsPartitionStatus endpoint in OpsHandler
- Backend: Add partition query methods in OpsRepository
- Backend: Add UsageLogsPartitionStatus type in OpsService
- Frontend: Add OpsPartitionStatusCard component
- Frontend: Add partition status display in OpsDashboard
- i18n: Add Chinese and English translations

The partition status card shows:
- Whether usage_logs is partitioned
- Current row count vs threshold (100K)
- Partition count (if partitioned)
- Warning message when partitioning is recommended

This allows administrators to monitor partition status directly
from the ops dashboard without checking server logs.
This commit is contained in:
User
2026-04-16 23:16:17 +08:00
parent eb5adbbae5
commit 60d15d2ba4
10 changed files with 409 additions and 1 deletions

View File

@@ -950,6 +950,23 @@ export interface OpsSystemLogSinkHealth {
last_error?: string
}
// ==================== Usage Logs Partition Management ====================
export interface UsageLogsPartitionStatus {
is_partitioned: boolean
row_count: number
partition_count: number
threshold_rows: number
needs_partitioning: boolean
warning_level: 'none' | 'info' | 'warning'
last_checked_at: string
}
export async function getUsageLogsPartitionStatus(): Promise<UsageLogsPartitionStatus> {
const { data } = await apiClient.get<UsageLogsPartitionStatus>('/admin/ops/partition-status')
return data
}
export interface OpsErrorLog {
id: number
created_at: string
@@ -1450,7 +1467,10 @@ export const opsAPI = {
updateMetricThresholds,
listSystemLogs,
cleanupSystemLogs,
getSystemLogSinkHealth
getSystemLogSinkHealth,
// Usage logs partition management
getUsageLogsPartitionStatus
}
export default opsAPI

View File

@@ -4190,6 +4190,27 @@ export default {
errorAccounts: 'Errors {count}',
loadFailed: 'Failed to load concurrency data'
},
partition: {
title: 'usage_logs Partition Status',
loadFailed: 'Failed to load partition status',
noData: 'No partition data',
rowCount: 'Row Count',
threshold: 'Threshold',
partitionCount: 'Partitions',
lastChecked: 'Last Checked',
status: {
partitioned: 'Partitioned',
notPartitioned: 'Not Partitioned',
needsPartitioning: 'Needs Partitioning'
},
warning: {
title: 'Row count exceeds threshold, partitioning recommended',
slowQueries: 'Slow queries and dashboard latency',
indexBloat: 'Index bloat and maintenance overhead',
backupTime: 'Longer backup/restore times',
manualMigration: 'See migrations/035_usage_logs_partitioning.sql for manual migration.'
}
},
realtime: {
title: 'Realtime',
connected: 'Realtime connected',

View File

@@ -4355,6 +4355,27 @@ export default {
errorAccounts: '异常 {count}',
loadFailed: '加载并发数据失败'
},
partition: {
title: 'usage_logs 分区状态',
loadFailed: '加载分区状态失败',
noData: '暂无分区数据',
rowCount: '数据行数',
threshold: '阈值',
partitionCount: '分区数',
lastChecked: '检查时间',
status: {
partitioned: '已分区',
notPartitioned: '未分区',
needsPartitioning: '需要分区'
},
warning: {
title: '数据量已超过阈值,建议进行分区',
slowQueries: '查询和仪表盘可能变慢',
indexBloat: '索引膨胀和维护开销增加',
backupTime: '备份/恢复时间延长',
manualMigration: '请参考 migrations/035_usage_logs_partitioning.sql 进行手动迁移。'
}
},
realtime: {
title: '实时信息',
connected: '实时已连接',

View File

@@ -96,6 +96,11 @@
<!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && showAlertEvents && !(loading && !hasLoadedOnce)" />
<!-- Partition Status -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 md:grid-cols-2">
<OpsPartitionStatusCard />
</div>
<!-- System Logs -->
<OpsSystemLogTable
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
@@ -166,6 +171,7 @@ import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsOpenAITokenStatsCard from './components/OpsOpenAITokenStatsCard.vue'
import OpsSystemLogTable from './components/OpsSystemLogTable.vue'
import OpsPartitionStatusCard from './components/OpsPartitionStatusCard.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import Icon from '@/components/icons/Icon.vue'
import Card from '@/components/common/Card.vue'
import { opsAPI, type UsageLogsPartitionStatus } from '@/api/admin/ops'
import { formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const status = ref<UsageLogsPartitionStatus | null>(null)
async function loadStatus() {
loading.value = true
try {
status.value = await opsAPI.getUsageLogsPartitionStatus()
} catch (err: any) {
console.error('[OpsPartitionStatusCard] Failed to load partition status', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.partition.loadFailed'))
} finally {
loading.value = false
}
}
onMounted(() => {
loadStatus()
})
function formatRowCount(count: number): string {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(2)}M`
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(2)}K`
}
return count.toString()
}
</script>
<template>
<Card class="overflow-hidden">
<template #header>
<div class="flex items-center justify-between px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-dark-100">
{{ t('admin.ops.partition.title') }}
</h3>
<button
v-if="!loading"
class="text-gray-400 hover:text-gray-600 dark:text-dark-400 dark:hover:text-dark-300"
@click="loadStatus"
>
<Icon name="refresh" class="h-4 w-4" />
</button>
</div>
</template>
<div v-if="loading" class="px-4 py-6">
<div class="animate-pulse space-y-3">
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-1/2"></div>
</div>
</div>
<div v-else-if="status" class="px-4 py-4">
<!-- Status Banner -->
<div
:class="[
'mb-4 rounded-lg p-3 flex items-center gap-2',
{
'bg-green-50 dark:bg-green-900/20': status.warning_level === 'none',
'bg-yellow-50 dark:bg-yellow-900/20': status.warning_level === 'info',
'bg-red-50 dark:bg-red-900/20': status.warning_level === 'warning'
}
]"
>
<Icon
:name="status.warning_level === 'none' ? 'checkCircle' : status.warning_level === 'warning' ? 'exclamationTriangle' : 'infoCircle'"
:class="[
'h-5 w-5',
{
'text-green-500 dark:text-green-400': status.warning_level === 'none',
'text-yellow-500 dark:text-yellow-400': status.warning_level === 'info',
'text-red-500 dark:text-red-400': status.warning_level === 'warning'
}
]"
/>
<span
:class="[
'text-sm font-medium',
{
'text-green-700 dark:text-green-300': status.warning_level === 'none',
'text-yellow-700 dark:text-yellow-300': status.warning_level === 'info',
'text-red-700 dark:text-red-300': status.warning_level === 'warning'
}
]"
>
{{ status.is_partitioned
? t('admin.ops.partition.status.partitioned')
: status.needs_partitioning
? t('admin.ops.partition.status.needsPartitioning')
: t('admin.ops.partition.status.notPartitioned')
}}
</span>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg bg-gray-50 dark:bg-dark-800 p-3">
<div class="text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.ops.partition.rowCount') }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-dark-100">
{{ formatRowCount(status.row_count) }}
</div>
</div>
<div class="rounded-lg bg-gray-50 dark:bg-dark-800 p-3">
<div class="text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.ops.partition.threshold') }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-dark-100">
{{ formatRowCount(status.threshold_rows) }}
</div>
</div>
<div v-if="status.is_partitioned" class="rounded-lg bg-gray-50 dark:bg-dark-800 p-3">
<div class="text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.ops.partition.partitionCount') }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-dark-100">
{{ status.partition_count }}
</div>
</div>
<div class="rounded-lg bg-gray-50 dark:bg-dark-800 p-3">
<div class="text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.ops.partition.lastChecked') }}
</div>
<div class="mt-1 text-sm text-gray-700 dark:text-dark-200">
{{ formatDateTime(status.last_checked_at) }}
</div>
</div>
</div>
<!-- Warning Message -->
<div
v-if="status.needs_partitioning"
class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-600 dark:text-red-400"
>
<p class="font-medium mb-2">{{ t('admin.ops.partition.warning.title') }}</p>
<ul class="list-disc list-inside space-y-1 text-xs">
<li>{{ t('admin.ops.partition.warning.slowQueries') }}</li>
<li>{{ t('admin.ops.partition.warning.indexBloat') }}</li>
<li>{{ t('admin.ops.partition.warning.backupTime') }}</li>
</ul>
<p class="mt-2 text-xs">
{{ t('admin.ops.partition.warning.manualMigration') }}
</p>
</div>
</div>
<div v-else class="px-4 py-6 text-sm text-gray-500 dark:text-dark-400">
{{ t('admin.ops.partition.noData') }}
</div>
</Card>
</template>