feat: add affinity clients hover popover with last active times

- New API: GET /accounts/:id/affinity-clients returns client list with
  last_active timestamps from Redis ZSET scores
- New Lua script: get_affinity_clients_with_scores.lua uses ZREVRANGEBYSCORE
  WITHSCORES to return both client IDs and their timestamps
- New frontend component: AffinityBadge.vue replaces inline badge with
  hover popover that lazy-loads client details on first hover
- Shows client ID (monospace) + relative time (e.g. "3h ago")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-07 13:50:39 +08:00
co-authored by Claude Opus 4.6
parent 5f5c41cef6
commit 9b382a2dc7
18 changed files with 338 additions and 24 deletions
@@ -1417,6 +1417,42 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}
// GetAffinityClients returns the list of affinity clients for an account with last active timestamps.
// GET /api/v1/admin/accounts/:id/affinity-clients
func (h *AccountHandler) GetAffinityClients(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if !account.IsClientAffinityEnabled() {
response.Success(c, []service.AffinityClient{})
return
}
if h.gatewayCache == nil || len(account.GroupIDs) == 0 {
response.Success(c, []service.AffinityClient{})
return
}
clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores(
c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL,
)
if err != nil {
response.Success(c, []service.AffinityClient{})
return
}
response.Success(c, clients)
}
// GetTempUnschedulable handles getting temporary unschedulable status
// GET /api/v1/admin/accounts/:id/temp-unschedulable
func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) {
+62 -4
View File
@@ -26,11 +26,14 @@ var (
getAffinityCountLua string
//go:embed lua/get_affinity_clients.lua
getAffinityClientsLua string
//go:embed lua/get_affinity_clients_with_scores.lua
getAffinityClientsWithScoresLua string
getAffinityScript = redis.NewScript(getAffinityLua)
updateAffinityScript = redis.NewScript(updateAffinityLua)
getAffinityCountScript = redis.NewScript(getAffinityCountLua)
getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
getAffinityScript = redis.NewScript(getAffinityLua)
updateAffinityScript = redis.NewScript(updateAffinityLua)
getAffinityCountScript = redis.NewScript(getAffinityCountLua)
getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua)
)
type gatewayCache struct {
@@ -211,3 +214,58 @@ func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accou
}
return result, nil
}
// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间戳,去重取最近)。
func (c *gatewayCache) GetAccountAffinityClientsWithScores(
ctx context.Context,
accountID int64,
groupIDs []int64,
ttl time.Duration,
) ([]service.AffinityClient, error) {
if len(groupIDs) == 0 {
return nil, nil
}
now := time.Now().Unix()
expireThreshold := now - int64(ttl.Seconds())
ensureScriptLoaded(ctx, c.rdb, getAffinityClientsWithScoresScript)
pipe := c.rdb.Pipeline()
cmds := make([]*redis.Cmd, len(groupIDs))
for i, gID := range groupIDs {
key := buildAffinityReverseKey(gID, accountID)
cmds[i] = getAffinityClientsWithScoresScript.Run(ctx, pipe, []string{key}, expireThreshold)
}
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}
// 合并跨组结果,同一 clientID 取最近的 lastActive
seen := make(map[string]int64) // clientID → max timestamp
for _, cmd := range cmds {
vals, _ := cmd.StringSlice()
// vals 格式: [clientID1, score1, clientID2, score2, ...]
for j := 0; j+1 < len(vals); j += 2 {
clientID := vals[j]
ts, _ := strconv.ParseInt(vals[j+1], 10, 64)
if existing, ok := seen[clientID]; !ok || ts > existing {
seen[clientID] = ts
}
}
}
result := make([]service.AffinityClient, 0, len(seen))
for clientID, ts := range seen {
result = append(result, service.AffinityClient{
ClientID: clientID,
LastActive: time.Unix(ts, 0),
})
}
// 按最后活跃时间降序排序
service.SortAffinityClients(result)
return result, nil
}
@@ -0,0 +1,6 @@
-- 清理过期成员后返回反向索引的 clientID 列表及其 score(最后活跃时间戳)
-- KEYS[1] = client_affinity_rev:{groupID}:{accountID}
-- ARGV[1] = 过期阈值时间戳 (now - ttl)
-- 返回: {clientID1, score1, clientID2, score2, ...}(按最近使用降序)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
return redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', '-inf', 'WITHSCORES')
+1
View File
@@ -253,6 +253,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats)
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota)
accounts.GET("/:id/affinity-clients", h.Admin.Account.GetAffinityClients)
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
@@ -42,6 +42,9 @@ func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _
func (c *stubSmartRetryCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (c *stubSmartRetryCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
// mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream
type mockSmartRetryUpstream struct {
@@ -52,6 +52,9 @@ func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, gr
func (m *mockAffinityCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (m *mockAffinityCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
// ---------------------------------------------------------------------------
// Helper: 构造启用了客户端亲和的 Anthropic 账号
@@ -155,6 +155,9 @@ func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.C
func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
s.listByGroupCalls.Add(1)
@@ -247,6 +247,9 @@ func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Con
func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
type mockGroupRepoForGateway struct {
groups map[int64]*Group
@@ -380,6 +380,21 @@ type GatewayCache interface {
// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)
// accountGroups: map[accountID][]groupID
GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间)
GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error)
}
// AffinityClient 亲和客户端信息(含最后活跃时间)
type AffinityClient struct {
ClientID string `json:"client_id"`
LastActive time.Time `json:"last_active"`
}
// SortAffinityClients 按最后活跃时间降序排序
func SortAffinityClients(clients []AffinityClient) {
sort.Slice(clients, func(i, j int) bool {
return clients[i].LastActive.After(clients[j].LastActive)
})
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -300,6 +300,9 @@ func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Conte
func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
@@ -293,6 +293,9 @@ func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int
func (c *stubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (c *stubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
now := time.Now()
@@ -205,6 +205,9 @@ func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ con
func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
return nil, nil
}
func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) {
probe := &openAIWSStateStoreTimeoutProbeCache{}
+3
View File
@@ -109,6 +109,9 @@ func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int6
func (c StubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
return map[int64][]string{}, nil
}
func (c StubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) {
return nil, nil
}
// ============================================================
// StubSessionLimitCache — service.SessionLimitCache 的空实现
+14 -1
View File
@@ -571,6 +571,18 @@ export async function validateSoraSessionToken(
return data
}
/**
* Get affinity clients for an account with last active timestamps
* @param id - Account ID
* @returns List of affinity clients
*/
export async function getAffinityClients(id: number): Promise<{ client_id: string; last_active: string }[]> {
const { data } = await apiClient.get<{ client_id: string; last_active: string }[]>(
`/admin/accounts/${id}/affinity-clients`
)
return data
}
export const accountsAPI = {
list,
listWithEtag,
@@ -604,7 +616,8 @@ export const accountsAPI = {
syncFromCrs,
exportData,
importData,
getAntigravityDefaultModelMapping
getAntigravityDefaultModelMapping,
getAffinityClients
}
export default accountsAPI
@@ -0,0 +1,172 @@
<template>
<div class="relative" ref="containerRef">
<span
:class="badgeClass"
class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0 cursor-pointer"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ count }}
</span>
<!-- Popover -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showPopover"
class="fixed z-50 w-72 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
:style="popoverStyle"
@mouseenter="handlePopoverEnter"
@mouseleave="handlePopoverLeave"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-3 py-2 dark:border-dark-700">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityClients', { count }) }}
</span>
<span v-if="loading" class="text-xs text-gray-400">
<svg class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
</div>
<!-- Client list -->
<div class="max-h-60 overflow-y-auto">
<div v-if="loading && clients.length === 0" class="px-3 py-4 text-center text-xs text-gray-400">
{{ t('common.loading') }}...
</div>
<div v-else-if="clients.length === 0" class="px-3 py-4 text-center text-xs text-gray-400">
{{ t('admin.accounts.affinityNoClients') }}
</div>
<div v-else class="divide-y divide-gray-50 dark:divide-dark-700">
<div
v-for="(client, index) in clients"
:key="index"
class="flex items-center justify-between px-3 py-1.5"
>
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 truncate mr-2" :title="client.client_id">
{{ client.client_id }}
</span>
<span class="text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0">
{{ formatRelativeTime(client.last_active) }}
</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getAffinityClients } from '@/api/admin/accounts'
interface Props {
accountId: number
count: number
}
const props = defineProps<Props>()
const { t } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const showPopover = ref(false)
const loading = ref(false)
const clients = ref<{ client_id: string; last_active: string }[]>([])
let loaded = false
let hideTimer: ReturnType<typeof setTimeout> | null = null
let showTimer: ReturnType<typeof setTimeout> | null = null
const badgeClass = computed(() => {
const c = props.count
if (c >= 16) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (c >= 6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (c > 0) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
})
const popoverStyle = computed(() => {
if (!containerRef.value) return {}
const rect = containerRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let top = rect.bottom + 6
let left = rect.left - 40
// 下方空间不足时显示在上方
if (top + 280 > viewportHeight) {
top = Math.max(8, rect.top - 280)
}
// 右侧空间不足时向左偏移
if (left + 288 > viewportWidth) {
left = Math.max(8, viewportWidth - 296)
}
// 不超出左边界
if (left < 8) left = 8
return { top: `${top}px`, left: `${left}px` }
})
function clearTimers() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
if (showTimer) { clearTimeout(showTimer); showTimer = null }
}
function handleMouseEnter() {
clearTimers()
showTimer = setTimeout(() => {
showPopover.value = true
if (!loaded) fetchClients()
}, 200)
}
function handleMouseLeave() {
clearTimers()
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
}
function handlePopoverEnter() {
clearTimers()
}
function handlePopoverLeave() {
clearTimers()
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
}
async function fetchClients() {
loading.value = true
try {
clients.value = await getAffinityClients(props.accountId)
loaded = true
} catch {
clients.value = []
} finally {
loading.value = false
}
}
function formatRelativeTime(isoStr: string): string {
const now = Date.now()
const then = new Date(isoStr).getTime()
const diffSec = Math.floor((now - then) / 1000)
if (diffSec < 60) return t('common.justNow', 'just now')
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
return `${Math.floor(diffSec / 86400)}d ago`
}
</script>
+1
View File
@@ -244,6 +244,7 @@ export default {
// Common
common: {
loading: 'Loading...',
justNow: 'just now',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
+1
View File
@@ -244,6 +244,7 @@ export default {
// Common
common: {
loading: '加载中...',
justNow: '刚刚',
save: '保存',
cancel: '取消',
delete: '删除',
+6 -19
View File
@@ -177,12 +177,11 @@
</template>
<template #cell-status="{ row }">
<div class="flex items-center gap-1.5">
<span v-if="row.client_affinity_enabled && row.affinity_client_count != null"
:class="affinityBadgeClass(row.affinity_client_count)"
:title="affinityTooltip(row)"
class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0">
{{ row.affinity_client_count }}
</span>
<AffinityBadge
v-if="row.client_affinity_enabled && row.affinity_client_count != null"
:account-id="row.id"
:count="row.affinity_client_count"
/>
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</div>
</template>
@@ -310,6 +309,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
import ScheduledTestsPanel from '@/components/admin/account/ScheduledTestsPanel.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AffinityBadge from '@/components/account/AffinityBadge.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
@@ -406,19 +406,6 @@ const buildDefaultTodayStats = (): WindowStats => ({
user_cost: 0
})
function affinityBadgeClass(count: number): string {
if (count >= 16) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (count >= 6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (count > 0) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}
function affinityTooltip(row: Account): string {
const clients = row.affinity_clients
if (!clients || clients.length === 0) return t('admin.accounts.affinityNoClients')
return t('admin.accounts.affinityClients', { count: clients.length }) + '\n' + clients.join('\n')
}
const refreshTodayStatsBatch = async () => {
if (hiddenColumns.has('today_stats')) {
todayStatsLoading.value = false