feat: add Sora admin page and integrate DB/Redis Prometheus metrics
- Create SoraAdminView with overview, user stats, and generations tabs - Add /admin/sora route for Sora management - Add i18n support (zh/en) for Sora admin page - Extract Prometheus metrics to prommetrics package to avoid import cycles - Integrate SetDBConnections/SetRedisConnections in OpsMetricsCollector
This commit is contained in:
182
backend/internal/prommetrics/metrics.go
Normal file
182
backend/internal/prommetrics/metrics.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Package prommetrics provides Prometheus metrics registration and update functions.
|
||||
// This package is intentionally separate from routes to avoid import cycles.
|
||||
package prommetrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
// Prometheus registry
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Custom metrics
|
||||
httpRequestsTotal = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "sub2api_http_requests_total",
|
||||
Help: "Total number of HTTP requests",
|
||||
},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "sub2api_http_request_duration_seconds",
|
||||
Help: "HTTP request duration in seconds",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
|
||||
dbConnectionsActive = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_active",
|
||||
Help: "Number of active database connections",
|
||||
},
|
||||
)
|
||||
|
||||
dbConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_idle",
|
||||
Help: "Number of idle database connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_total",
|
||||
Help: "Total number of Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_idle",
|
||||
Help: "Number of idle Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
accountActiveTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_accounts_active_total",
|
||||
Help: "Total number of active accounts",
|
||||
},
|
||||
)
|
||||
|
||||
requestQueueDepth = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_request_queue_depth",
|
||||
Help: "Current request queue depth",
|
||||
},
|
||||
)
|
||||
|
||||
opsHealthScore = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_ops_health_score",
|
||||
Help: "Overall system health score (0-100)",
|
||||
},
|
||||
)
|
||||
|
||||
errorRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_error_rate",
|
||||
Help: "Current error rate",
|
||||
},
|
||||
)
|
||||
|
||||
successRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_success_rate",
|
||||
Help: "Current success rate",
|
||||
},
|
||||
)
|
||||
|
||||
qps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_qps",
|
||||
Help: "Queries per second",
|
||||
},
|
||||
)
|
||||
|
||||
tps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_tps",
|
||||
Help: "Tokens per second",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Register custom metrics
|
||||
Registry.MustRegister(
|
||||
httpRequestsTotal,
|
||||
httpRequestDuration,
|
||||
dbConnectionsActive,
|
||||
dbConnectionsIdle,
|
||||
redisConnectionsTotal,
|
||||
redisConnectionsIdle,
|
||||
accountActiveTotal,
|
||||
requestQueueDepth,
|
||||
opsHealthScore,
|
||||
errorRate,
|
||||
successRate,
|
||||
qps,
|
||||
tps,
|
||||
)
|
||||
}
|
||||
|
||||
// RecordHTTPRequest records an HTTP request for Prometheus metrics
|
||||
func RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) {
|
||||
httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(statusCode)).Inc()
|
||||
httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
// SetDBConnections sets database connection metrics
|
||||
func SetDBConnections(active, idle int) {
|
||||
dbConnectionsActive.Set(float64(active))
|
||||
dbConnectionsIdle.Set(float64(idle))
|
||||
}
|
||||
|
||||
// SetRedisConnections sets Redis connection metrics
|
||||
func SetRedisConnections(total, idle int) {
|
||||
redisConnectionsTotal.Set(float64(total))
|
||||
redisConnectionsIdle.Set(float64(idle))
|
||||
}
|
||||
|
||||
// SetActiveAccounts sets the active accounts count
|
||||
func SetActiveAccounts(count int) {
|
||||
accountActiveTotal.Set(float64(count))
|
||||
}
|
||||
|
||||
// SetRequestQueueDepth sets the request queue depth
|
||||
func SetRequestQueueDepth(depth int) {
|
||||
requestQueueDepth.Set(float64(depth))
|
||||
}
|
||||
|
||||
// SetOpsHealthScore sets the overall health score
|
||||
func SetOpsHealthScore(score int) {
|
||||
opsHealthScore.Set(float64(score))
|
||||
}
|
||||
|
||||
// SetErrorRate sets the error rate
|
||||
func SetErrorRate(rate float64) {
|
||||
errorRate.Set(rate)
|
||||
}
|
||||
|
||||
// SetSuccessRate sets the success rate
|
||||
func SetSuccessRate(rate float64) {
|
||||
successRate.Set(rate)
|
||||
}
|
||||
|
||||
// SetQPS sets queries per second
|
||||
func SetQPS(value float64) {
|
||||
qps.Set(value)
|
||||
}
|
||||
|
||||
// SetTPS sets tokens per second
|
||||
func SetTPS(value float64) {
|
||||
tps.Set(value)
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/prommetrics"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
@@ -20,125 +19,8 @@ type HealthChecker interface {
|
||||
var (
|
||||
healthChecker HealthChecker
|
||||
healthCheckerOnce sync.Once
|
||||
|
||||
// Prometheus metrics
|
||||
prometheusRegistry = prometheus.NewRegistry()
|
||||
|
||||
// Custom metrics
|
||||
httpRequestsTotal = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "sub2api_http_requests_total",
|
||||
Help: "Total number of HTTP requests",
|
||||
},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "sub2api_http_request_duration_seconds",
|
||||
Help: "HTTP request duration in seconds",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
|
||||
dbConnectionsActive = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_active",
|
||||
Help: "Number of active database connections",
|
||||
},
|
||||
)
|
||||
|
||||
dbConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_idle",
|
||||
Help: "Number of idle database connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_total",
|
||||
Help: "Total number of Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_idle",
|
||||
Help: "Number of idle Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
accountActiveTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_accounts_active_total",
|
||||
Help: "Total number of active accounts",
|
||||
},
|
||||
)
|
||||
|
||||
requestQueueDepth = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_request_queue_depth",
|
||||
Help: "Current request queue depth",
|
||||
},
|
||||
)
|
||||
|
||||
opsHealthScore = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_ops_health_score",
|
||||
Help: "Overall system health score (0-100)",
|
||||
},
|
||||
)
|
||||
|
||||
errorRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_error_rate",
|
||||
Help: "Current error rate",
|
||||
},
|
||||
)
|
||||
|
||||
successRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_success_rate",
|
||||
Help: "Current success rate",
|
||||
},
|
||||
)
|
||||
|
||||
qps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_qps",
|
||||
Help: "Queries per second",
|
||||
},
|
||||
)
|
||||
|
||||
tps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_tps",
|
||||
Help: "Tokens per second",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Register custom metrics
|
||||
prometheusRegistry.MustRegister(
|
||||
httpRequestsTotal,
|
||||
httpRequestDuration,
|
||||
dbConnectionsActive,
|
||||
dbConnectionsIdle,
|
||||
redisConnectionsTotal,
|
||||
redisConnectionsIdle,
|
||||
accountActiveTotal,
|
||||
requestQueueDepth,
|
||||
opsHealthScore,
|
||||
errorRate,
|
||||
successRate,
|
||||
qps,
|
||||
tps,
|
||||
)
|
||||
}
|
||||
|
||||
// SetHealthChecker sets the health checker instance (called during app initialization)
|
||||
func SetHealthChecker(checker HealthChecker) {
|
||||
healthCheckerOnce.Do(func() {
|
||||
@@ -157,8 +39,8 @@ func RegisterCommonRoutes(r *gin.Engine) {
|
||||
// Liveness check - for Kubernetes liveness probe
|
||||
r.GET("/live", livenessHandler)
|
||||
|
||||
// Prometheus metrics endpoint
|
||||
r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{})))
|
||||
// Prometheus metrics endpoint - use the shared registry from prommetrics package
|
||||
r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(prommetrics.Registry, promhttp.HandlerOpts{})))
|
||||
|
||||
// Claude Code telemetry logs (ignore, return 200 directly)
|
||||
r.POST("/api/event_logging/batch", func(c *gin.Context) {
|
||||
@@ -275,57 +157,54 @@ func livenessHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Prometheus metric update functions
|
||||
// Prometheus metric update functions - delegate to prommetrics package
|
||||
|
||||
// RecordHTTPRequest records an HTTP request for Prometheus metrics
|
||||
func RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) {
|
||||
httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(statusCode)).Inc()
|
||||
httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
|
||||
prommetrics.RecordHTTPRequest(method, path, statusCode, duration)
|
||||
}
|
||||
|
||||
// SetDBConnections sets database connection metrics
|
||||
func SetDBConnections(active, idle int) {
|
||||
dbConnectionsActive.Set(float64(active))
|
||||
dbConnectionsIdle.Set(float64(idle))
|
||||
prommetrics.SetDBConnections(active, idle)
|
||||
}
|
||||
|
||||
// SetRedisConnections sets Redis connection metrics
|
||||
func SetRedisConnections(total, idle int) {
|
||||
redisConnectionsTotal.Set(float64(total))
|
||||
redisConnectionsIdle.Set(float64(idle))
|
||||
prommetrics.SetRedisConnections(total, idle)
|
||||
}
|
||||
|
||||
// SetActiveAccounts sets the active accounts count
|
||||
func SetActiveAccounts(count int) {
|
||||
accountActiveTotal.Set(float64(count))
|
||||
prommetrics.SetActiveAccounts(count)
|
||||
}
|
||||
|
||||
// SetRequestQueueDepth sets the request queue depth
|
||||
func SetRequestQueueDepth(depth int) {
|
||||
requestQueueDepth.Set(float64(depth))
|
||||
prommetrics.SetRequestQueueDepth(depth)
|
||||
}
|
||||
|
||||
// SetOpsHealthScore sets the overall health score
|
||||
func SetOpsHealthScore(score int) {
|
||||
opsHealthScore.Set(float64(score))
|
||||
prommetrics.SetOpsHealthScore(score)
|
||||
}
|
||||
|
||||
// SetErrorRate sets the error rate
|
||||
func SetErrorRate(rate float64) {
|
||||
errorRate.Set(rate)
|
||||
prommetrics.SetErrorRate(rate)
|
||||
}
|
||||
|
||||
// SetSuccessRate sets the success rate
|
||||
func SetSuccessRate(rate float64) {
|
||||
successRate.Set(rate)
|
||||
prommetrics.SetSuccessRate(rate)
|
||||
}
|
||||
|
||||
// SetQPS sets queries per second
|
||||
func SetQPS(value float64) {
|
||||
qps.Set(value)
|
||||
prommetrics.SetQPS(value)
|
||||
}
|
||||
|
||||
// SetTPS sets tokens per second
|
||||
func SetTPS(value float64) {
|
||||
tps.Set(value)
|
||||
prommetrics.SetTPS(value)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/prommetrics"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
@@ -270,6 +271,12 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
|
||||
active, idle := c.dbPoolStats()
|
||||
redisTotal, redisIdle, redisStatsOK := c.redisPoolStats()
|
||||
|
||||
// Update Prometheus metrics for DB/Redis connections
|
||||
prommetrics.SetDBConnections(active, idle)
|
||||
if redisStatsOK {
|
||||
prommetrics.SetRedisConnections(redisTotal, redisIdle)
|
||||
}
|
||||
|
||||
successCount, tokenConsumed, err := c.queryUsageCounts(ctx, windowStart, windowEnd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query usage counts: %w", err)
|
||||
|
||||
@@ -3124,6 +3124,29 @@ export default {
|
||||
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
|
||||
},
|
||||
|
||||
// Sora Management
|
||||
sora: {
|
||||
title: 'Sora Management',
|
||||
description: 'Manage Sora video generation service and user quotas',
|
||||
overview: 'Overview',
|
||||
userStats: 'User Stats',
|
||||
generations: 'Generations',
|
||||
totalUsers: 'Total Users',
|
||||
totalGenerations: 'Total Generations',
|
||||
activeGenerations: 'Active Generations',
|
||||
totalStorage: 'Total Storage',
|
||||
byStatus: 'By Status',
|
||||
byModel: 'By Model',
|
||||
quota: 'Quota',
|
||||
used: 'Used',
|
||||
model: 'Model',
|
||||
status: 'Status',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
clearStorage: 'Clear Storage',
|
||||
confirmClearStorage: 'Are you sure you want to clear this user\'s Sora storage? This action cannot be undone.'
|
||||
},
|
||||
|
||||
// Redeem Codes
|
||||
redeem: {
|
||||
title: 'Redeem Code Management',
|
||||
|
||||
@@ -3250,6 +3250,29 @@ export default {
|
||||
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
|
||||
},
|
||||
|
||||
// Sora Management
|
||||
sora: {
|
||||
title: 'Sora 管理',
|
||||
description: '管理 Sora 视频生成服务和用户配额',
|
||||
overview: '概览',
|
||||
userStats: '用户统计',
|
||||
generations: '生成记录',
|
||||
totalUsers: '总用户数',
|
||||
totalGenerations: '总生成数',
|
||||
activeGenerations: '活跃生成',
|
||||
totalStorage: '总存储',
|
||||
byStatus: '按状态分布',
|
||||
byModel: '按模型分布',
|
||||
quota: '配额',
|
||||
used: '已用',
|
||||
model: '模型',
|
||||
status: '状态',
|
||||
size: '大小',
|
||||
createdAt: '创建时间',
|
||||
clearStorage: '清除存储',
|
||||
confirmClearStorage: '确定要清除该用户的 Sora 存储吗?此操作不可撤销。'
|
||||
},
|
||||
|
||||
// Redeem Codes Management
|
||||
redeem: {
|
||||
title: '兑换码管理',
|
||||
|
||||
@@ -407,6 +407,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.proxies.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/sora',
|
||||
name: 'AdminSora',
|
||||
component: () => import('@/views/admin/SoraAdminView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Sora Management',
|
||||
titleKey: 'admin.sora.title',
|
||||
descriptionKey: 'admin.sora.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/redeem',
|
||||
name: 'AdminRedeem',
|
||||
|
||||
430
frontend/src/views/admin/SoraAdminView.vue
Normal file
430
frontend/src/views/admin/SoraAdminView.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import soraAdminAPI, { type SoraSystemStats, type SoraUserStats, type SoraGenerationAdmin } from '@/api/admin/sora'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
import Icon from '@/components/ui/Icon.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BasePagination from '@/components/ui/BasePagination.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// State
|
||||
const loading = ref(true)
|
||||
const systemStats = ref<SoraSystemStats | null>(null)
|
||||
const userStats = ref<SoraUserStats[]>([])
|
||||
const generations = ref<SoraGenerationAdmin[]>([])
|
||||
const activeTab = ref<'overview' | 'users' | 'generations'>('overview')
|
||||
|
||||
// Pagination
|
||||
const userPage = ref(1)
|
||||
const userPageSize = ref(20)
|
||||
const userTotal = ref(0)
|
||||
|
||||
const genPage = ref(1)
|
||||
const genPageSize = ref(20)
|
||||
const genTotal = ref(0)
|
||||
|
||||
// Filters
|
||||
const userSearch = ref('')
|
||||
const genStatusFilter = ref('')
|
||||
const genModelFilter = ref('')
|
||||
|
||||
// Computed
|
||||
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize.value))
|
||||
const genTotalPages = computed(() => Math.ceil(genTotal.value / genPageSize.value))
|
||||
|
||||
// Format helpers
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-600 dark:text-green-400'
|
||||
case 'failed': return 'text-red-600 dark:text-red-400'
|
||||
case 'cancelled': return 'text-gray-500 dark:text-gray-400'
|
||||
case 'pending': return 'text-yellow-600 dark:text-yellow-400'
|
||||
case 'generating': return 'text-blue-600 dark:text-blue-400'
|
||||
default: return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// API calls
|
||||
async function fetchSystemStats() {
|
||||
try {
|
||||
systemStats.value = await soraAdminAPI.getSystemStats()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch system stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserStats() {
|
||||
try {
|
||||
const res = await soraAdminAPI.listUserStats({
|
||||
page: userPage.value,
|
||||
page_size: userPageSize.value,
|
||||
search: userSearch.value || undefined
|
||||
})
|
||||
userStats.value = res.items
|
||||
userTotal.value = res.total
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGenerations() {
|
||||
try {
|
||||
const res = await soraAdminAPI.listGenerations({
|
||||
page: genPage.value,
|
||||
page_size: genPageSize.value,
|
||||
status: genStatusFilter.value || undefined,
|
||||
model: genModelFilter.value || undefined
|
||||
})
|
||||
generations.value = res.items
|
||||
genTotal.value = res.total
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch generations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearUserStorage(userId: number) {
|
||||
if (!confirm(t('admin.sora.confirmClearStorage'))) return
|
||||
try {
|
||||
await soraAdminAPI.clearUserStorage(userId)
|
||||
await fetchUserStats()
|
||||
} catch (err) {
|
||||
console.error('Failed to clear user storage:', err)
|
||||
alert(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
await Promise.all([
|
||||
fetchSystemStats(),
|
||||
fetchUserStats(),
|
||||
fetchGenerations()
|
||||
])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function onUserPageChange(page: number) {
|
||||
userPage.value = page
|
||||
fetchUserStats()
|
||||
}
|
||||
|
||||
function onGenPageChange(page: number) {
|
||||
genPage.value = page
|
||||
fetchGenerations()
|
||||
}
|
||||
|
||||
function onTabChange(tab: 'overview' | 'users' | 'generations') {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.sora.title') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.sora.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
@click="onTabChange('overview')"
|
||||
:class="[
|
||||
activeTab === 'overview'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.sora.overview') }}
|
||||
</button>
|
||||
<button
|
||||
@click="onTabChange('users')"
|
||||
:class="[
|
||||
activeTab === 'users'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.sora.userStats') }}
|
||||
</button>
|
||||
<button
|
||||
@click="onTabChange('generations')"
|
||||
:class="[
|
||||
activeTab === 'generations'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.sora.generations') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview' && systemStats" class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<Icon name="users" size="md" class="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.sora.totalUsers') }}
|
||||
</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ systemStats.total_users }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<Icon name="video" size="md" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.sora.totalGenerations') }}
|
||||
</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ systemStats.total_generations }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<Icon name="activity" size="md" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.sora.activeGenerations') }}
|
||||
</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ systemStats.active_generations }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||
<Icon name="database" size="md" class="text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.sora.totalStorage') }}
|
||||
</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatBytes(systemStats.total_storage_bytes) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Status -->
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.sora.byStatus') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<div v-for="(count, status) in systemStats.by_status" :key="status" class="text-center">
|
||||
<p class="text-2xl font-bold" :class="getStatusColor(status)">{{ count }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Model -->
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.sora.byModel') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div v-for="(count, model) in systemStats.by_model" :key="model" class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ count }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ model }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div v-if="activeTab === 'users'" class="space-y-4">
|
||||
<!-- Search -->
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
v-model="userSearch"
|
||||
type="text"
|
||||
:placeholder="t('common.search')"
|
||||
class="input flex-1"
|
||||
@keyup.enter="fetchUserStats"
|
||||
/>
|
||||
<BaseButton @click="fetchUserStats">{{ t('common.search') }}</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.users.username') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.users.email') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.quota') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.used') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.generations') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('common.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="user in userStats" :key="user.user_id">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.username }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ user.email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.quota_bytes) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.used_bytes) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.generations_count }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<BaseButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="clearUserStorage(user.user_id)"
|
||||
>
|
||||
{{ t('admin.sora.clearStorage') }}
|
||||
</BaseButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<BasePagination
|
||||
v-if="userTotalPages > 1"
|
||||
:current="userPage"
|
||||
:total="userTotalPages"
|
||||
@change="onUserPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Generations Tab -->
|
||||
<div v-if="activeTab === 'generations'" class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<select v-model="genStatusFilter" class="input" @change="fetchGenerations">
|
||||
<option value="">{{ t('common.allStatus') }}</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="generating">Generating</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="genModelFilter" class="input" @change="fetchGenerations">
|
||||
<option value="">{{ t('common.allModels') }}</option>
|
||||
<option value="sora2">Sora 2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.users.username') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.model') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.size') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{{ t('admin.sora.createdAt') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="gen in generations" :key="gen.id">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.id }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ gen.username }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.model }}</td>
|
||||
<td class="px-4 py-3 text-sm" :class="getStatusColor(gen.status)">{{ gen.status }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(gen.file_size_bytes) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ formatDate(gen.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<BasePagination
|
||||
v-if="genTotalPages > 1"
|
||||
:current="genPage"
|
||||
:total="genTotalPages"
|
||||
@change="onGenPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user