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

Merge pull request #1108 from DaydreamCoding/feat/admin-group-capacity-and-usage

feat(admin): 分组管理列表新增用量、账号分类与容量列
parents 17cdcebd d4cc9871
...@@ -64,8 +64,10 @@ type Group struct { ...@@ -64,8 +64,10 @@ type Group struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
AccountGroups []AccountGroup AccountGroups []AccountGroup
AccountCount int64 AccountCount int64
ActiveAccountCount int64
RateLimitedAccountCount int64
} }
func (g *Group) IsActive() bool { func (g *Group) IsActive() bool {
......
package service
import (
"context"
"time"
)
// GroupCapacitySummary holds aggregated capacity for a single group.
type GroupCapacitySummary struct {
GroupID int64 `json:"group_id"`
ConcurrencyUsed int `json:"concurrency_used"`
ConcurrencyMax int `json:"concurrency_max"`
SessionsUsed int `json:"sessions_used"`
SessionsMax int `json:"sessions_max"`
RPMUsed int `json:"rpm_used"`
RPMMax int `json:"rpm_max"`
}
// GroupCapacityService aggregates per-group capacity from runtime data.
type GroupCapacityService struct {
accountRepo AccountRepository
groupRepo GroupRepository
concurrencyService *ConcurrencyService
sessionLimitCache SessionLimitCache
rpmCache RPMCache
}
// NewGroupCapacityService creates a new GroupCapacityService.
func NewGroupCapacityService(
accountRepo AccountRepository,
groupRepo GroupRepository,
concurrencyService *ConcurrencyService,
sessionLimitCache SessionLimitCache,
rpmCache RPMCache,
) *GroupCapacityService {
return &GroupCapacityService{
accountRepo: accountRepo,
groupRepo: groupRepo,
concurrencyService: concurrencyService,
sessionLimitCache: sessionLimitCache,
rpmCache: rpmCache,
}
}
// GetAllGroupCapacity returns capacity summary for all active groups.
func (s *GroupCapacityService) GetAllGroupCapacity(ctx context.Context) ([]GroupCapacitySummary, error) {
groups, err := s.groupRepo.ListActive(ctx)
if err != nil {
return nil, err
}
results := make([]GroupCapacitySummary, 0, len(groups))
for i := range groups {
cap, err := s.getGroupCapacity(ctx, groups[i].ID)
if err != nil {
// Skip groups with errors, return partial results
continue
}
cap.GroupID = groups[i].ID
results = append(results, cap)
}
return results, nil
}
func (s *GroupCapacityService) getGroupCapacity(ctx context.Context, groupID int64) (GroupCapacitySummary, error) {
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
if err != nil {
return GroupCapacitySummary{}, err
}
if len(accounts) == 0 {
return GroupCapacitySummary{}, nil
}
// Collect account IDs and config values
accountIDs := make([]int64, 0, len(accounts))
sessionTimeouts := make(map[int64]time.Duration)
var concurrencyMax, sessionsMax, rpmMax int
for i := range accounts {
acc := &accounts[i]
accountIDs = append(accountIDs, acc.ID)
concurrencyMax += acc.Concurrency
if ms := acc.GetMaxSessions(); ms > 0 {
sessionsMax += ms
timeout := time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
if timeout <= 0 {
timeout = 5 * time.Minute
}
sessionTimeouts[acc.ID] = timeout
}
if rpm := acc.GetBaseRPM(); rpm > 0 {
rpmMax += rpm
}
}
// Batch query runtime data from Redis
concurrencyMap, _ := s.concurrencyService.GetAccountConcurrencyBatch(ctx, accountIDs)
var sessionsMap map[int64]int
if sessionsMax > 0 && s.sessionLimitCache != nil {
sessionsMap, _ = s.sessionLimitCache.GetActiveSessionCountBatch(ctx, accountIDs, sessionTimeouts)
}
var rpmMap map[int64]int
if rpmMax > 0 && s.rpmCache != nil {
rpmMap, _ = s.rpmCache.GetRPMBatch(ctx, accountIDs)
}
// Aggregate
var concurrencyUsed, sessionsUsed, rpmUsed int
for _, id := range accountIDs {
concurrencyUsed += concurrencyMap[id]
if sessionsMap != nil {
sessionsUsed += sessionsMap[id]
}
if rpmMap != nil {
rpmUsed += rpmMap[id]
}
}
return GroupCapacitySummary{
ConcurrencyUsed: concurrencyUsed,
ConcurrencyMax: concurrencyMax,
SessionsUsed: sessionsUsed,
SessionsMax: sessionsMax,
RPMUsed: rpmUsed,
RPMMax: rpmMax,
}, nil
}
...@@ -27,7 +27,7 @@ type GroupRepository interface { ...@@ -27,7 +27,7 @@ type GroupRepository interface {
ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error)
ExistsByName(ctx context.Context, name string) (bool, error) ExistsByName(ctx context.Context, name string) (bool, error)
GetAccountCount(ctx context.Context, groupID int64) (int64, error) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error)
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
...@@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any, ...@@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any,
} }
// 获取账号数量 // 获取账号数量
accountCount, err := s.groupRepo.GetAccountCount(ctx, id) accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("get account count: %w", err) return nil, fmt.Errorf("get account count: %w", err)
} }
......
...@@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([ ...@@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([
func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) { func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) {
return false, nil return false, nil
} }
func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) { func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) {
return 0, nil return 0, 0, nil
} }
func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
......
...@@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err ...@@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err
func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) { func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
panic("unexpected ExistsByName call") panic("unexpected ExistsByName call")
} }
func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) { func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) {
panic("unexpected GetAccountCount call") panic("unexpected GetAccountCount call")
} }
func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
......
...@@ -486,4 +486,5 @@ var ProviderSet = wire.NewSet( ...@@ -486,4 +486,5 @@ var ProviderSet = wire.NewSet(
ProvideIdempotencyCleanupService, ProvideIdempotencyCleanupService,
ProvideScheduledTestService, ProvideScheduledTestService,
ProvideScheduledTestRunnerService, ProvideScheduledTestRunnerService,
NewGroupCapacityService,
) )
...@@ -218,6 +218,34 @@ export async function batchSetGroupRateMultipliers( ...@@ -218,6 +218,34 @@ export async function batchSetGroupRateMultipliers(
return data return data
} }
/**
* Get usage summary (today + cumulative cost) for all groups
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
* @returns Array of group usage summaries
*/
export async function getUsageSummary(
timezone?: string
): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> {
const { data } = await apiClient.get<
{ group_id: number; today_cost: number; total_cost: number }[]
>('/admin/groups/usage-summary', {
params: timezone ? { timezone } : undefined
})
return data
}
/**
* Get capacity summary (concurrency/sessions/RPM) for all active groups
*/
export async function getCapacitySummary(): Promise<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
> {
const { data } = await apiClient.get<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
>('/admin/groups/capacity-summary')
return data
}
export const groupsAPI = { export const groupsAPI = {
list, list,
getAll, getAll,
...@@ -232,7 +260,9 @@ export const groupsAPI = { ...@@ -232,7 +260,9 @@ export const groupsAPI = {
getGroupRateMultipliers, getGroupRateMultipliers,
clearGroupRateMultipliers, clearGroupRateMultipliers,
batchSetGroupRateMultipliers, batchSetGroupRateMultipliers,
updateSortOrder updateSortOrder,
getUsageSummary,
getCapacitySummary
} }
export default groupsAPI export default groupsAPI
<template>
<div class="flex flex-col gap-1">
<!-- 并发槽位 -->
<div class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(concurrencyUsed, concurrencyMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ concurrencyUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ concurrencyMax }}</span>
</span>
</div>
<!-- 会话数 -->
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(sessionsUsed, sessionsMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ sessionsUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ sessionsMax }}</span>
</span>
</div>
<!-- RPM -->
<div v-if="rpmMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(rpmUsed, rpmMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ rpmUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ rpmMax }}</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
concurrencyUsed: number
concurrencyMax: number
sessionsUsed: number
sessionsMax: number
rpmUsed: number
rpmMax: number
}
withDefaults(defineProps<Props>(), {
concurrencyUsed: 0,
concurrencyMax: 0,
sessionsUsed: 0,
sessionsMax: 0,
rpmUsed: 0,
rpmMax: 0
})
function capacityClass(used: number, max: number): string {
if (max > 0 && used >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (used > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}
</script>
...@@ -1505,6 +1505,8 @@ export default { ...@@ -1505,6 +1505,8 @@ export default {
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
type: 'Type', type: 'Type',
accounts: 'Accounts', accounts: 'Accounts',
capacity: 'Capacity',
usage: 'Usage',
status: 'Status', status: 'Status',
actions: 'Actions', actions: 'Actions',
billingType: 'Billing Type', billingType: 'Billing Type',
...@@ -1513,6 +1515,12 @@ export default { ...@@ -1513,6 +1515,12 @@ export default {
userNotes: 'Notes', userNotes: 'Notes',
userStatus: 'Status' userStatus: 'Status'
}, },
usageToday: 'Today',
usageTotal: 'Total',
accountsAvailable: 'Avail:',
accountsRateLimited: 'Limited:',
accountsTotal: 'Total:',
accountsUnit: '',
rateAndAccounts: '{rate}x rate · {count} accounts', rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts', accountsCount: '{count} accounts',
form: { form: {
......
...@@ -1561,6 +1561,8 @@ export default { ...@@ -1561,6 +1561,8 @@ export default {
priority: '优先级', priority: '优先级',
apiKeys: 'API 密钥数', apiKeys: 'API 密钥数',
accounts: '账号数', accounts: '账号数',
capacity: '容量',
usage: '用量',
status: '状态', status: '状态',
actions: '操作', actions: '操作',
billingType: '计费类型', billingType: '计费类型',
...@@ -1569,6 +1571,12 @@ export default { ...@@ -1569,6 +1571,12 @@ export default {
userNotes: '备注', userNotes: '备注',
userStatus: '状态' userStatus: '状态'
}, },
usageToday: '今日',
usageTotal: '累计',
accountsAvailable: '可用:',
accountsRateLimited: '限流:',
accountsTotal: '总量:',
accountsUnit: '个账号',
form: { form: {
name: '名称', name: '名称',
description: '描述', description: '描述',
......
...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group { ...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
// 分组下账号数量(仅管理员可见) // 分组下账号数量(仅管理员可见)
account_count?: number account_count?: number
active_account_count?: number
rate_limited_account_count?: number
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string default_mapped_model?: string
......
...@@ -158,12 +158,51 @@ ...@@ -158,12 +158,51 @@
</span> </span>
</template> </template>
<template #cell-account_count="{ value }"> <template #cell-account_count="{ row }">
<span <div class="space-y-0.5 text-xs">
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300" <div>
> <span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }} <span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
</span> <span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
</div>
</template>
<template #cell-capacity="{ row }">
<GroupCapacityBadge
v-if="capacityMap.get(row.id)"
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
:rpm-max="capacityMap.get(row.id)!.rpmMax"
/>
<span v-else class="text-xs text-gray-400"></span>
</template>
<template #cell-usage="{ row }">
<div v-if="usageLoading" class="text-xs text-gray-400"></div>
<div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
</div>
</div>
</template> </template>
<template #cell-status="{ value }"> <template #cell-status="{ value }">
...@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue' ...@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue' import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue' import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
import GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch' import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
...@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [ ...@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true }, { key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true }, { key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true }, { key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
{ key: 'capacity', label: t('admin.groups.columns.capacity'), sortable: false },
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true }, { key: 'status', label: t('admin.groups.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false } { key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
]) ])
...@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => { ...@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const loading = ref(false) const loading = ref(false)
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
const usageLoading = ref(false)
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map())
const searchQuery = ref('') const searchQuery = ref('')
const filters = reactive({ const filters = reactive({
platform: '', platform: '',
...@@ -2301,6 +2346,8 @@ const loadGroups = async () => { ...@@ -2301,6 +2346,8 @@ const loadGroups = async () => {
groups.value = response.items groups.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
loadUsageSummary()
loadCapacitySummary()
} catch (error: any) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return return
...@@ -2314,6 +2361,49 @@ const loadGroups = async () => { ...@@ -2314,6 +2361,49 @@ const loadGroups = async () => {
} }
} }
const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0)
if (cost >= 100) return cost.toFixed(1)
return cost.toFixed(2)
}
const loadUsageSummary = async () => {
usageLoading.value = true
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const data = await adminAPI.groups.getUsageSummary(tz)
const map = new Map<number, { today_cost: number; total_cost: number }>()
for (const item of data) {
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
}
usageMap.value = map
} catch (error) {
console.error('Error loading group usage summary:', error)
} finally {
usageLoading.value = false
}
}
const loadCapacitySummary = async () => {
try {
const data = await adminAPI.groups.getCapacitySummary()
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>()
for (const item of data) {
map.set(item.group_id, {
concurrencyUsed: item.concurrency_used,
concurrencyMax: item.concurrency_max,
sessionsUsed: item.sessions_used,
sessionsMax: item.sessions_max,
rpmUsed: item.rpm_used,
rpmMax: item.rpm_max
})
}
capacityMap.value = map
} catch (error) {
console.error('Error loading group capacity summary:', error)
}
}
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => { const handleSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
......
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