diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index 44accc8f..7c9cfc6f 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -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) +} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 5154b269..31855c4e 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -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 +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d60e6573..cd374c84 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -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) } } diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 04bf91c8..caf35dde 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -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 { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index cd3974a0..e6d1ad5a 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -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 +} diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 55cf411c..701eaf65 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -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 { + const { data } = await apiClient.get('/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 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 74eaf9b3..039d1a01 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 95adc664..486e9fd9 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '实时已连接', diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue index 50bc5249..0467ee69 100644 --- a/frontend/src/views/admin/ops/OpsDashboard.vue +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -96,6 +96,11 @@ + +
+ +
+ +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(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() +} + + + \ No newline at end of file