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:
@@ -923,3 +923,22 @@ func parseOpsDuration(v string) (time.Duration, bool) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Usage Logs Partition Management ====================
|
||||
|
||||
// GetUsageLogsPartitionStatus returns partition status of usage_logs table.
|
||||
// GET /api/v1/admin/ops/partition-status
|
||||
func (h *OpsHandler) GetUsageLogsPartitionStatus(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.opsService.GetUsageLogsPartitionStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, status)
|
||||
}
|
||||
|
||||
@@ -1526,3 +1526,78 @@ func opsNullInt16(v *int16) any {
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
|
||||
// ==================== Usage Logs Partition Management ====================
|
||||
|
||||
// IsUsageLogsPartitioned checks if usage_logs table is partitioned.
|
||||
func (r *opsRepository) IsUsageLogsPartitioned(ctx context.Context) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, fmt.Errorf("nil ops repository")
|
||||
}
|
||||
|
||||
var isPartitioned bool
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM pg_partitioned_table pt
|
||||
JOIN pg_class c ON c.oid = pt.partrelid
|
||||
WHERE c.relname = 'usage_logs'
|
||||
)
|
||||
`).Scan(&isPartitioned)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check usage_logs partitioned: %w", err)
|
||||
}
|
||||
return isPartitioned, nil
|
||||
}
|
||||
|
||||
// GetUsageLogsRowCount returns the approximate row count of usage_logs table.
|
||||
func (r *opsRepository) GetUsageLogsRowCount(ctx context.Context) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, fmt.Errorf("nil ops repository")
|
||||
}
|
||||
|
||||
var rowCount int64
|
||||
// Use pg_class.reltuples for fast approximate count (avoid slow COUNT(*) on large tables)
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(
|
||||
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'usage_logs'),
|
||||
0
|
||||
)
|
||||
`).Scan(&rowCount)
|
||||
if err != nil {
|
||||
// Fallback to actual COUNT if pg_class estimate fails
|
||||
err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM usage_logs`).Scan(&rowCount)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get usage_logs row count: %w", err)
|
||||
}
|
||||
}
|
||||
return rowCount, nil
|
||||
}
|
||||
|
||||
// GetUsageLogsPartitionCount returns the number of partitions for usage_logs table.
|
||||
func (r *opsRepository) GetUsageLogsPartitionCount(ctx context.Context) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, fmt.Errorf("nil ops repository")
|
||||
}
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM pg_class c
|
||||
WHERE c.relkind = 'r'
|
||||
AND c.relname LIKE 'usage_logs_%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pg_inherits i
|
||||
WHERE i.inhrelid = c.oid
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pg_class parent
|
||||
WHERE parent.oid = i.inhparent
|
||||
AND parent.relname = 'usage_logs'
|
||||
)
|
||||
)
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get usage_logs partition count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ops.GET("/dashboard/error-trend", h.Admin.Ops.GetDashboardErrorTrend)
|
||||
ops.GET("/dashboard/error-distribution", h.Admin.Ops.GetDashboardErrorDistribution)
|
||||
ops.GET("/dashboard/openai-token-stats", h.Admin.Ops.GetDashboardOpenAITokenStats)
|
||||
|
||||
// Usage logs partition management
|
||||
ops.GET("/partition-status", h.Admin.Ops.GetUsageLogsPartitionStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ type OpsRepository interface {
|
||||
UpsertDailyMetrics(ctx context.Context, startTime, endTime time.Time) error
|
||||
GetLatestHourlyBucketStart(ctx context.Context) (time.Time, bool, error)
|
||||
GetLatestDailyBucketDate(ctx context.Context) (time.Time, bool, error)
|
||||
|
||||
// Usage logs partition management
|
||||
IsUsageLogsPartitioned(ctx context.Context) (bool, error)
|
||||
GetUsageLogsRowCount(ctx context.Context) (int64, error)
|
||||
GetUsageLogsPartitionCount(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type OpsInsertErrorLogInput struct {
|
||||
|
||||
@@ -724,3 +724,72 @@ func sanitizeErrorBodyForStorage(raw string, maxBytes int) (sanitized string, tr
|
||||
}
|
||||
return raw, false
|
||||
}
|
||||
|
||||
// ==================== Usage Logs Partition Management ====================
|
||||
|
||||
// UsageLogsPartitionStatus represents the partition status of usage_logs table.
|
||||
type UsageLogsPartitionStatus struct {
|
||||
IsPartitioned bool `json:"is_partitioned"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
PartitionCount int `json:"partition_count"`
|
||||
ThresholdRows int64 `json:"threshold_rows"` // 100000
|
||||
NeedsPartitioning bool `json:"needs_partitioning"` // rowCount >= threshold && !isPartitioned
|
||||
WarningLevel string `json:"warning_level"` // "none", "info", "warning"
|
||||
LastCheckedAt string `json:"last_checked_at"`
|
||||
}
|
||||
|
||||
// GetUsageLogsPartitionStatus returns the current partition status of usage_logs table.
|
||||
func (s *OpsService) GetUsageLogsPartitionStatus(ctx context.Context) (*UsageLogsPartitionStatus, error) {
|
||||
if s.opsRepo == nil {
|
||||
return nil, errors.New("ops repository not available")
|
||||
}
|
||||
|
||||
status := &UsageLogsPartitionStatus{
|
||||
ThresholdRows: 100000,
|
||||
LastCheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Check if usage_logs is partitioned
|
||||
isPartitioned, err := s.opsRepo.IsUsageLogsPartitioned(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[Ops] GetUsageLogsPartitionStatus check partitioned failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
status.IsPartitioned = isPartitioned
|
||||
|
||||
// Get row count
|
||||
rowCount, err := s.opsRepo.GetUsageLogsRowCount(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[Ops] GetUsageLogsPartitionStatus get row count failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
status.RowCount = rowCount
|
||||
|
||||
// Determine partition count if partitioned
|
||||
if isPartitioned {
|
||||
count, err := s.opsRepo.GetUsageLogsPartitionCount(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[Ops] GetUsageLogsPartitionStatus get partition count failed: %v", err)
|
||||
// Non-critical, continue
|
||||
} else {
|
||||
status.PartitionCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// Determine warning level and needs_partitioning
|
||||
if isPartitioned {
|
||||
status.WarningLevel = "none"
|
||||
status.NeedsPartitioning = false
|
||||
} else if rowCount >= status.ThresholdRows {
|
||||
status.WarningLevel = "warning"
|
||||
status.NeedsPartitioning = true
|
||||
} else if rowCount >= 50000 {
|
||||
status.WarningLevel = "info"
|
||||
status.NeedsPartitioning = false
|
||||
} else {
|
||||
status.WarningLevel = "none"
|
||||
status.NeedsPartitioning = false
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '实时已连接',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user