feat: add Sora admin page and integrate DB/Redis Prometheus metrics
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

- 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:
User
2026-04-16 12:01:12 +08:00
parent 7fa795e6a4
commit c4007afe6b
7 changed files with 691 additions and 135 deletions

View 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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -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: '兑换码管理',

View File

@@ -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',

View 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>