Unverified Commit 9dcd3cd4 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #754 from xvhuan/perf/admin-core-large-dataset

perf(admin): 优化后台大数据场景加载性能(仪表盘/用户/账号/Ops)
parents 49767ccc f6fe5b55
...@@ -120,6 +120,31 @@ export interface GroupStatsResponse { ...@@ -120,6 +120,31 @@ export interface GroupStatsResponse {
end_date: string end_date: string
} }
export interface DashboardSnapshotV2Params extends TrendParams {
include_stats?: boolean
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
include_users_trend?: boolean
users_trend_limit?: number
}
export interface DashboardSnapshotV2Stats extends DashboardStats {
uptime: number
}
export interface DashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
stats?: DashboardSnapshotV2Stats
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
users_trend?: UserUsageTrendPoint[]
}
/** /**
* Get group usage statistics * Get group usage statistics
* @param params - Query parameters for filtering * @param params - Query parameters for filtering
...@@ -130,6 +155,16 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta ...@@ -130,6 +155,16 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
return data return data
} }
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/
export async function getSnapshotV2(params?: DashboardSnapshotV2Params): Promise<DashboardSnapshotV2Response> {
const { data } = await apiClient.get<DashboardSnapshotV2Response>('/admin/dashboard/snapshot-v2', {
params
})
return data
}
export interface ApiKeyTrendParams extends TrendParams { export interface ApiKeyTrendParams extends TrendParams {
limit?: number limit?: number
} }
...@@ -233,6 +268,7 @@ export const dashboardAPI = { ...@@ -233,6 +268,7 @@ export const dashboardAPI = {
getUsageTrend, getUsageTrend,
getModelStats, getModelStats,
getGroupStats, getGroupStats,
getSnapshotV2,
getApiKeyUsageTrend, getApiKeyUsageTrend,
getUserUsageTrend, getUserUsageTrend,
getBatchUsersUsage, getBatchUsersUsage,
......
...@@ -259,6 +259,13 @@ export interface OpsErrorDistributionResponse { ...@@ -259,6 +259,13 @@ export interface OpsErrorDistributionResponse {
items: OpsErrorDistributionItem[] items: OpsErrorDistributionItem[]
} }
export interface OpsDashboardSnapshotV2Response {
generated_at: string
overview: OpsDashboardOverview
throughput_trend: OpsThroughputTrendResponse
error_trend: OpsErrorTrendResponse
}
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d' export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
export interface OpsOpenAITokenStatsItem { export interface OpsOpenAITokenStatsItem {
...@@ -1004,6 +1011,24 @@ export async function getDashboardOverview( ...@@ -1004,6 +1011,24 @@ export async function getDashboardOverview(
return data return data
} }
export async function getDashboardSnapshotV2(
params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
start_time?: string
end_time?: string
platform?: string
group_id?: number | null
mode?: OpsQueryMode
},
options: OpsRequestOptions = {}
): Promise<OpsDashboardSnapshotV2Response> {
const { data } = await apiClient.get<OpsDashboardSnapshotV2Response>('/admin/ops/dashboard/snapshot-v2', {
params,
signal: options.signal
})
return data
}
export async function getThroughputTrend( export async function getThroughputTrend(
params: { params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
...@@ -1329,6 +1354,7 @@ async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise< ...@@ -1329,6 +1354,7 @@ async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<
} }
export const opsAPI = { export const opsAPI = {
getDashboardSnapshotV2,
getDashboardOverview, getDashboardOverview,
getThroughputTrend, getThroughputTrend,
getLatencyHistogram, getLatencyHistogram,
......
...@@ -22,6 +22,7 @@ export async function list( ...@@ -22,6 +22,7 @@ export async function list(
role?: 'admin' | 'user' role?: 'admin' | 'user'
search?: string search?: string
attributes?: Record<number, string> // attributeId -> value attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
...@@ -33,7 +34,8 @@ export async function list( ...@@ -33,7 +34,8 @@ export async function list(
page_size: pageSize, page_size: pageSize,
status: filters?.status, status: filters?.status,
role: filters?.role, role: filters?.role,
search: filters?.search search: filters?.search,
include_subscriptions: filters?.include_subscriptions
} }
// Add attribute filters as attr[id]=value // Add attribute filters as attr[id]=value
......
...@@ -359,7 +359,7 @@ const exportingData = ref(false) ...@@ -359,7 +359,7 @@ const exportingData = ref(false)
const showColumnDropdown = ref(false) const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null) const columnDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(new Set()) const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier'] const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
...@@ -546,7 +546,7 @@ const { ...@@ -546,7 +546,7 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', group: '', search: '' } initialParams: { platform: '', type: '', status: '', group: '', search: '', lite: '1' }
}) })
const resetAutoRefreshCache = () => { const resetAutoRefreshCache = () => {
...@@ -689,6 +689,7 @@ const refreshAccountsIncrementally = async () => { ...@@ -689,6 +689,7 @@ const refreshAccountsIncrementally = async () => {
type?: string type?: string
status?: string status?: string
search?: string search?: string
lite?: string
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
) )
......
...@@ -316,6 +316,7 @@ const trendData = ref<TrendDataPoint[]>([]) ...@@ -316,6 +316,7 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([]) const userTrend = ref<UserUsageTrendPoint[]>([])
let chartLoadSeq = 0 let chartLoadSeq = 0
let usersTrendLoadSeq = 0
// Helper function to format date in local timezone // Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => { const formatLocalDate = (date: Date): string => {
...@@ -523,67 +524,74 @@ const onDateRangeChange = (range: { ...@@ -523,67 +524,74 @@ const onDateRangeChange = (range: {
} }
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardSnapshot = async (includeStats: boolean) => {
const currentSeq = ++chartLoadSeq
if (includeStats && !stats.value) {
loading.value = true loading.value = true
try {
stats.value = await adminAPI.dashboard.getStats()
} catch (error) {
appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard stats:', error)
} finally {
loading.value = false
} }
}
const loadChartData = async () => {
const currentSeq = ++chartLoadSeq
chartsLoading.value = true chartsLoading.value = true
userTrendLoading.value = true
try { try {
const params = { const response = await adminAPI.dashboard.getSnapshotV2({
start_date: startDate.value, start_date: startDate.value,
end_date: endDate.value, end_date: endDate.value,
granularity: granularity.value granularity: granularity.value,
} include_stats: includeStats,
include_trend: true,
const [trendResponse, modelResponse] = await Promise.all([ include_model_stats: true,
adminAPI.dashboard.getUsageTrend(params), include_group_stats: false,
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }) include_users_trend: false
]) })
if (currentSeq !== chartLoadSeq) return if (currentSeq !== chartLoadSeq) return
trendData.value = trendResponse.trend || [] if (includeStats && response.stats) {
modelStats.value = modelResponse.models || [] stats.value = response.stats
}
trendData.value = response.trend || []
modelStats.value = response.models || []
} catch (error) { } catch (error) {
if (currentSeq !== chartLoadSeq) return if (currentSeq !== chartLoadSeq) return
console.error('Error loading chart data:', error) appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard snapshot:', error)
} finally { } finally {
if (currentSeq !== chartLoadSeq) return if (currentSeq !== chartLoadSeq) return
loading.value = false
chartsLoading.value = false chartsLoading.value = false
} }
}
const loadUsersTrend = async () => {
const currentSeq = ++usersTrendLoadSeq
userTrendLoading.value = true
try { try {
const params = { const response = await adminAPI.dashboard.getUserUsageTrend({
start_date: startDate.value, start_date: startDate.value,
end_date: endDate.value, end_date: endDate.value,
granularity: granularity.value, granularity: granularity.value,
limit: 12 limit: 12
} })
const userResponse = await adminAPI.dashboard.getUserUsageTrend(params) if (currentSeq !== usersTrendLoadSeq) return
if (currentSeq !== chartLoadSeq) return userTrend.value = response.trend || []
userTrend.value = userResponse.trend || []
} catch (error) { } catch (error) {
if (currentSeq !== chartLoadSeq) return if (currentSeq !== usersTrendLoadSeq) return
console.error('Error loading user trend:', error) console.error('Error loading users trend:', error)
userTrend.value = []
} finally { } finally {
if (currentSeq !== chartLoadSeq) return if (currentSeq !== usersTrendLoadSeq) return
userTrendLoading.value = false userTrendLoading.value = false
} }
} }
const loadDashboardStats = async () => {
await loadDashboardSnapshot(true)
void loadUsersTrend()
}
const loadChartData = async () => {
await loadDashboardSnapshot(false)
void loadUsersTrend()
}
onMounted(() => { onMounted(() => {
loadDashboardStats() loadDashboardStats()
loadChartData()
}) })
</script> </script>
......
...@@ -655,16 +655,28 @@ const saveColumnsToStorage = () => { ...@@ -655,16 +655,28 @@ const saveColumnsToStorage = () => {
// Toggle column visibility // Toggle column visibility
const toggleColumn = (key: string) => { const toggleColumn = (key: string) => {
const wasHidden = hiddenColumns.has(key)
if (hiddenColumns.has(key)) { if (hiddenColumns.has(key)) {
hiddenColumns.delete(key) hiddenColumns.delete(key)
} else { } else {
hiddenColumns.add(key) hiddenColumns.add(key)
} }
saveColumnsToStorage() saveColumnsToStorage()
if (wasHidden && (key === 'usage' || key.startsWith('attr_'))) {
refreshCurrentPageSecondaryData()
}
if (key === 'subscriptions') {
loadUsers()
}
} }
// Check if column is visible (not in hidden set) // Check if column is visible (not in hidden set)
const isColumnVisible = (key: string) => !hiddenColumns.has(key) const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage'))
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
const hasVisibleAttributeColumns = computed(() =>
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
)
// Filtered columns based on visibility // Filtered columns based on visibility
const columns = computed<Column[]>(() => const columns = computed<Column[]>(() =>
...@@ -776,6 +788,60 @@ const editingUser = ref<AdminUser | null>(null) ...@@ -776,6 +788,60 @@ const editingUser = ref<AdminUser | null>(null)
const deletingUser = ref<AdminUser | null>(null) const deletingUser = ref<AdminUser | null>(null)
const viewingUser = ref<AdminUser | null>(null) const viewingUser = ref<AdminUser | null>(null)
let abortController: AbortController | null = null let abortController: AbortController | null = null
let secondaryDataSeq = 0
const loadUsersSecondaryData = async (
userIds: number[],
signal?: AbortSignal,
expectedSeq?: number
) => {
if (userIds.length === 0) return
const tasks: Promise<void>[] = []
if (hasVisibleUsageColumn.value) {
tasks.push(
(async () => {
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
usageStats.value = usageResponse.stats
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load usage stats:', e)
}
})()
)
}
if (attributeDefinitions.value.length > 0 && hasVisibleAttributeColumns.value) {
tasks.push(
(async () => {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load user attribute values:', e)
}
})()
)
}
if (tasks.length > 0) {
await Promise.allSettled(tasks)
}
}
const refreshCurrentPageSecondaryData = () => {
const userIds = users.value.map((u) => u.id)
if (userIds.length === 0) return
const seq = ++secondaryDataSeq
void loadUsersSecondaryData(userIds, undefined, seq)
}
// Action Menu State // Action Menu State
const activeMenuId = ref<number | null>(null) const activeMenuId = ref<number | null>(null)
...@@ -913,7 +979,8 @@ const loadUsers = async () => { ...@@ -913,7 +979,8 @@ const loadUsers = async () => {
role: filters.role as any, role: filters.role as any,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value
}, },
{ signal } { signal }
) )
...@@ -923,38 +990,17 @@ const loadUsers = async () => { ...@@ -923,38 +990,17 @@ const loadUsers = async () => {
users.value = response.items users.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
usageStats.value = {}
userAttributeValues.value = {}
// Load usage stats and attribute values for all users in the list // Defer heavy secondary data so table can render first.
if (response.items.length > 0) { if (response.items.length > 0) {
const userIds = response.items.map((u) => u.id) const userIds = response.items.map((u) => u.id)
// Load usage stats const seq = ++secondaryDataSeq
try { window.setTimeout(() => {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds) if (signal.aborted || seq !== secondaryDataSeq) return
if (signal.aborted) { void loadUsersSecondaryData(userIds, signal, seq)
return }, 50)
}
usageStats.value = usageResponse.stats
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e)
}
// Load attribute values
if (attributeDefinitions.value.length > 0) {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal.aborted) {
return
}
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load user attribute values:', e)
}
}
} }
} catch (error: any) { } catch (error: any) {
const errorInfo = error as { name?: string; code?: string } const errorInfo = error as { name?: string; code?: string }
......
...@@ -586,6 +586,32 @@ async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortS ...@@ -586,6 +586,32 @@ async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortS
} }
} }
async function refreshCoreSnapshotWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
loadingErrorTrend.value = true
try {
const data = await opsAPI.getDashboardSnapshotV2(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
overview.value = data.overview
throughputTrend.value = data.throughput_trend
errorTrend.value = data.error_trend
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
// Fallback to legacy split endpoints when snapshot endpoint is unavailable.
await Promise.all([
refreshOverviewWithCancel(fetchSeq, signal),
refreshThroughputTrendWithCancel(fetchSeq, signal),
refreshErrorTrendWithCancel(fetchSeq, signal)
])
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingTrend.value = false
loadingErrorTrend.value = false
}
}
}
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) { async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return if (!opsEnabled.value) return
loadingLatency.value = true loadingLatency.value = true
...@@ -640,6 +666,14 @@ async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: Abor ...@@ -640,6 +666,14 @@ async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: Abor
} }
} }
async function refreshDeferredPanels(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
await Promise.all([
refreshLatencyHistogramWithCancel(fetchSeq, signal),
refreshErrorDistributionWithCancel(fetchSeq, signal)
])
}
function isOpsDisabledError(err: unknown): boolean { function isOpsDisabledError(err: unknown): boolean {
return ( return (
!!err && !!err &&
...@@ -662,12 +696,8 @@ async function fetchData() { ...@@ -662,12 +696,8 @@ async function fetchData() {
errorMessage.value = '' errorMessage.value = ''
try { try {
await Promise.all([ await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal), refreshCoreSnapshotWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal), refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
]) ])
if (fetchSeq !== dashboardFetchSeq) return if (fetchSeq !== dashboardFetchSeq) return
...@@ -680,6 +710,9 @@ async function fetchData() { ...@@ -680,6 +710,9 @@ async function fetchData() {
if (autoRefreshEnabled.value) { if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000) autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
} }
// Defer non-core visual panels to reduce initial blocking.
void refreshDeferredPanels(fetchSeq, dashboardFetchController.signal)
} catch (err) { } catch (err) {
if (!isOpsDisabledError(err)) { if (!isOpsDisabledError(err)) {
console.error('[ops] failed to fetch dashboard data', err) console.error('[ops] failed to fetch dashboard data', err)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment