feat(channel-monitor-v2): 实现用户端 Ops 风格被动监控界面

提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层,
并统一 useI18n 避免语言包未挂载时键路径裸露。
This commit is contained in:
IanShaw027
2026-08-07 11:05:32 +08:00
parent a5beecb92a
commit d8c5024cee
24 changed files with 4153 additions and 166 deletions
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apiClient } from '../client'
import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2'
afterEach(() => vi.restoreAllMocks())
describe('channel monitor V2 query serialization', () => {
it('uses repeated keys without bracket suffixes for array filters', () => {
const query = repeatedArrayParamsSerializer({
range: '90m',
platform: ['openai', 'grok'],
group_id: [1, 2],
model: undefined,
group_by: 'platform_group_model',
})
expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model')
expect(query).not.toContain('%5B%5D')
})
it('sends the matrix grouping with the shared filters', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: { coverage: {}, group_by: 'platform_group', items: [] },
})
await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true)
expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({
params: {
range: '24h',
platform: ['openai'],
group_id: [7],
model: undefined,
group_by: 'platform_group',
},
}))
})
})
+259
View File
@@ -0,0 +1,259 @@
import { apiClient } from './client'
export type MonitorRange = '90m' | '24h' | '7d' | '30d'
export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical'
/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */
export type HealthScoreBand =
| 'unknown'
| 'score0'
| 'score1'
| 'score2'
| 'score3'
| 'score4'
| 'score5'
| 'score6'
| 'score7'
| 'score8'
| 'score9'
| 'score10'
export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model'
export interface MonitorFilter {
range: MonitorRange
platforms: string[]
groupIds: number[]
models: string[]
}
export interface LatencyMetric {
sample_count: number
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms: number | null
}
export interface MonitorMetric {
success_requests: number
error_requests: number
request_count: number
token_count: number
rpm: number
tpm: number
error_rate: number
cache_rate: number
cache_rate_numerator: number
cache_rate_denominator: number
ttft: LatencyMetric
duration: LatencyMetric
upstream_affected_requests?: number
upstream_attempt_count?: number
}
export interface MonitorHealth {
overall: HealthState
error_rate: HealthState
ttft: HealthState
cache?: HealthState
/** 0–100 blended score when samples are sufficient. */
score?: number | null
error_rate_score?: number | null
ttft_score?: number | null
cache_score?: number | null
minimum_sample: number
thresholds?: {
minimum_sample?: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate?: number
critical_cache_rate?: number
error_weight: number
ttft_weight: number
cache_weight?: number
}
}
export interface MonitorCoverage {
requested_start: string
coverage_start: string
data_through: string
computed_at: string
aggregation_lag_seconds: number
coverage_complete: boolean
bucket_seconds: number
}
export interface MonitorConfig {
version: number
enabled: boolean
refresh_interval_seconds: 60 | 300
platforms: Array<{ platform: string; enabled: boolean; models: string[] }>
group_ids: number[]
health_thresholds: {
minimum_sample: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate: number
critical_cache_rate: number
error_weight: number
ttft_weight: number
cache_weight: number
}
/** Categories excluded from error_rate / health; still listed in error breakdown. */
ignored_error_categories?: string[]
}
/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */
export const MONITOR_ERROR_CATEGORIES = [
'content_policy',
'authentication',
'context_limit',
'invalid_request',
'model_unsupported',
'group_access',
'quota_or_balance',
'account_pool_unavailable',
'rate_or_capacity',
'timeout',
'transport_or_stream',
'upstream_forbidden',
'not_found',
'client_cancelled',
'upstream_5xx',
'internal',
'other',
] as const
export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number]
export interface MonitorSnapshot {
config: MonitorConfig
coverage: MonitorCoverage
metrics: MonitorMetric
health: MonitorHealth
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
}
export interface MonitorMatrixBucket {
bucket_start: string
metrics: MonitorMetric
health: MonitorHealth
}
export interface MonitorMatrixRow {
platform: string
group_id?: number
group_name?: string
model?: string
metrics: MonitorMetric
health: MonitorHealth
buckets: MonitorMatrixBucket[]
}
export interface MonitorMatrixResponse {
coverage: MonitorCoverage
group_by: MonitorMatrixGroupBy
items: MonitorMatrixRow[]
}
export interface MonitorDimensions {
platforms: Array<{ value: string; label: string; request_count: number }>
groups: Array<{ id: number; name: string; platform?: string; request_count: number }>
models: Array<{ value: string; label: string; platform?: string; request_count: number }>
}
export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth }
export interface MonitorErrorRow {
category: string
count: number
rate: number
details?: Array<{
platform?: string
model?: string
error_type?: string
status_code?: number
upstream_status_code?: number
message?: string
count: number
}>
/** true when category is in config.ignored_error_categories */
ignored?: boolean
}
export interface MonitorUserRow {
user_id?: number
rank: number
email?: string
username?: string
display_label: string
is_self: boolean
can_drilldown: boolean
metrics: MonitorMetric
}
function params(filter: MonitorFilter) {
return {
range: filter.range,
platform: filter.platforms.length ? filter.platforms : undefined,
group_id: filter.groupIds.length ? filter.groupIds : undefined,
model: filter.models.length ? filter.models : undefined,
}
}
export function repeatedArrayParamsSerializer(values: Record<string, unknown>): string {
const search = new URLSearchParams()
for (const [key, rawValue] of Object.entries(values)) {
if (rawValue == null || rawValue === '') continue
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
for (const value of entries) {
if (value != null && value !== '') search.append(key, String(value))
}
}
return search.toString()
}
const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record<string, unknown> = {}) => ({
params: { ...params(filter), ...extraParams },
paramsSerializer: { serialize: repeatedArrayParamsSerializer },
signal,
})
function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' }
export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorDimensions>(`${base(admin)}/dimensions`, requestConfig(filter, signal))
return data
}
export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorSnapshot>(`${base(admin)}/snapshot`, requestConfig(filter, signal))
return data
}
export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorMatrixResponse>(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy }))
return data
}
export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal))
return data
}
export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal))
return data
}
export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal))
return data
}
export async function getConfig() {
const { data } = await apiClient.get<MonitorConfig>('/admin/channel-monitor-v2/config')
return data
}
export async function updateConfig(config: MonitorConfig) {
const { data } = await apiClient.put<MonitorConfig>('/admin/channel-monitor-v2/config', config)
return data
}
+2
View File
@@ -67,6 +67,8 @@ const icons = {
xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z',
exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
// Awards
trophy: 'M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-4.5A3.375 3.375 0 0012.75 11.25h-1.5A3.375 3.375 0 008.25 14.25v4.5m8.25-12V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m0 0V9a.75.75 0 01-.75.75H9.75A.75.75 0 019 9V8.25m0 0V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m12 0h.008v.008h-.008V8.25zm-12 0h.008v.008H5.25V8.25z',
infoCircle: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
@@ -0,0 +1,271 @@
<template>
<div
ref="containerRef"
class="filter-menu relative"
:class="compact ? 'min-w-[6.5rem] sm:min-w-[7.25rem]' : 'min-w-[150px] sm:min-w-[160px]'"
>
<button
ref="triggerRef"
type="button"
class="select-trigger flex cursor-pointer list-none items-center justify-between gap-1.5 text-left"
:class="[
isOpen ? 'select-trigger-open' : '',
compact ? 'h-8 rounded-lg !px-2 !py-1 text-xs' : 'h-[42px]',
]"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-label="label"
@click="toggleOpen"
>
<span
class="select-value min-w-0 truncate"
:class="compact ? 'max-w-[5.5rem] sm:max-w-[6.5rem]' : 'max-w-[11rem]'"
>
{{ t('channelMonitorV2.filters.labelValue', { label, value: selectionLabel }) }}
</span>
<span class="select-icon shrink-0 text-gray-400 transition-transform" :class="isOpen ? 'rotate-180' : ''">
<Icon name="chevronDown" size="sm" />
</span>
</button>
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal dropdown filter-dropdown"
:class="[instanceId]"
:style="dropdownStyle"
role="listbox"
aria-multiselectable="true"
@click.stop
@mousedown.stop
>
<button
type="button"
class="dropdown-item select-option select-option-group flex w-full items-center justify-between border-b border-gray-100 px-4 py-2 text-left text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:border-dark-700 dark:text-gray-300 dark:hover:bg-dark-700"
@click="clear"
>
<span>{{ allLabel }}</span>
<Icon v-if="modelValue.length === 0" name="check" size="sm" class="text-primary-500" />
</button>
<button
v-for="option in options"
:key="option.value"
type="button"
role="option"
class="dropdown-item select-option flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:class="modelValue.includes(option.value) ? 'select-option-selected' : ''"
:aria-selected="modelValue.includes(option.value)"
@click="toggle(option.value)"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
class="checkbox flex h-4 w-4 items-center justify-center rounded border border-gray-300 bg-white text-primary-500 dark:border-dark-600 dark:bg-dark-900"
:class="modelValue.includes(option.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : ''"
>
<Icon v-if="modelValue.includes(option.value)" name="check" size="sm" class="text-primary-500" />
</span>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
</span>
<small v-if="option.count != null" class="text-xs text-gray-400">{{ formatCount(option.count) }}</small>
</button>
<p v-if="options.length === 0" class="px-4 py-3 text-center text-xs text-gray-400">{{ t('channelMonitorV2.filters.empty') }}</p>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import { monitorIntlLocale } from '@/features/channel-monitor-v2/monitorFormat'
interface FilterOption {
value: string
label: string
count?: number
}
const props = withDefaults(
defineProps<{
label: string
allLabel: string
modelValue: string[]
/** Options for this picker (parent may cascade by platform). */
options: FilterOption[]
/** Compact trigger for single-row toolbars. */
compact?: boolean
}>(),
{ compact: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const { t, locale } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const instanceId = `filter-select-${Math.random().toString(36).slice(2, 9)}`
const selectionLabel = computed(() => {
if (props.modelValue.length === 0) return props.allLabel
if (props.modelValue.length === 1) {
return props.options.find((item) => item.value === props.modelValue[0])?.label || props.modelValue[0]
}
return t('channelMonitorV2.filters.selectedCount', { count: props.modelValue.length })
})
const dropdownStyle = computed<CSSProperties>(() => {
const trigger = triggerRef.value
if (!trigger) return {}
const rect = trigger.getBoundingClientRect()
const padding = 8
const viewportRight = Math.max(padding, window.innerWidth - padding)
const left = Math.min(Math.max(padding, rect.left), viewportRight)
const availableWidth = Math.max(0, viewportRight - left)
const preferredMinWidth = Math.max(200, rect.width)
const minWidth = Math.min(preferredMinWidth, availableWidth)
return {
position: 'fixed' as const,
left: `${left}px`,
top: `${rect.bottom + 4}px`,
minWidth: `${minWidth}px`,
maxWidth: `${availableWidth}px`,
zIndex: '100000020',
}
})
function clear() {
emit('update:modelValue', [])
// Stay open so users can re-select without reopening.
}
function toggle(value: string) {
const selected = new Set(props.modelValue)
if (selected.has(value)) selected.delete(value)
else selected.add(value)
emit('update:modelValue', [...selected])
// Stay open on toggle (multi-select).
}
function toggleOpen() {
isOpen.value ? close() : open()
}
function open() {
isOpen.value = true
void nextTick(() => positionDropdown())
}
function close() {
isOpen.value = false
}
function positionDropdown() {
const trigger = triggerRef.value
const dropdown = dropdownRef.value
if (!trigger || !dropdown) return
const rect = trigger.getBoundingClientRect()
const padding = 8
const viewportRight = Math.max(padding, window.innerWidth - padding)
const left = Math.min(Math.max(padding, rect.left), viewportRight)
const availableWidth = Math.max(0, viewportRight - left)
const preferredMinWidth = Math.max(200, rect.width)
const minWidth = Math.min(preferredMinWidth, availableWidth)
dropdown.style.left = `${left}px`
dropdown.style.top = `${rect.bottom + 4}px`
dropdown.style.minWidth = `${minWidth}px`
dropdown.style.maxWidth = `${availableWidth}px`
}
function formatCount(value: number) {
return Intl.NumberFormat(locale.value || monitorIntlLocale(), {
notation: value >= 10000 ? 'compact' : 'standard',
}).format(value)
}
function onDocumentMouseDown(event: MouseEvent) {
if (!isOpen.value) return
const target = event.target as Node | null
if (!target) return
if (containerRef.value?.contains(target)) return
if (dropdownRef.value?.contains(target)) return
close()
}
function onDocumentKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
function onWindowChange() {
if (isOpen.value) positionDropdown()
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
positionDropdown()
document.addEventListener('mousedown', onDocumentMouseDown)
document.addEventListener('keydown', onDocumentKeyDown)
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
} else {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('keydown', onDocumentKeyDown)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('keydown', onDocumentKeyDown)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
})
</script>
<style scoped>
.select-trigger {
@apply flex w-full items-center justify-between gap-2;
@apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer;
}
.select-trigger-open {
@apply border-primary-500 ring-2 ring-primary-500/30;
}
.filter-menu summary::-webkit-details-marker {
display: none;
}
.filter-dropdown {
@apply w-max min-w-[200px] max-h-[min(50vh,360px)] overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800;
}
.dropdown-item {
@apply cursor-pointer;
}
.checkbox {
flex: none;
}
@media (max-width: 640px) {
.filter-menu {
min-width: 100%;
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<div
class="stat-card !min-h-[6.5rem] !rounded-3xl !border-0 !p-4 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
:title="title || undefined"
>
<div
v-if="state"
class="mt-1 h-2 w-2 shrink-0 rounded-full"
:class="dotClass"
aria-hidden="true"
></div>
<div class="min-w-0 flex-1">
<span class="stat-label text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ label }}</span>
<strong
class="stat-value mt-1 block overflow-visible text-xl tabular-nums leading-tight !text-clip !whitespace-normal"
:class="stateClass"
>{{ value }}</strong>
<div
v-if="detailParts.length > 1"
class="mt-1.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] leading-snug text-gray-400 dark:text-dark-400"
>
<span
v-for="(part, index) in detailParts"
:key="`${index}:${part}`"
class="whitespace-nowrap tabular-nums"
>{{ part }}</span>
</div>
<small
v-else-if="detail"
class="mt-1.5 block text-[11px] leading-snug text-gray-400 dark:text-dark-400"
>{{ detail }}</small>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { HealthState } from '@/api/channelMonitorV2'
const props = defineProps<{
label: string
value: string
detail: string
state?: HealthState
/** Exact numeric tooltip (e.g. uncompacted RPM/TPM). */
title?: string
}>()
/** Split "AVG 475ms · P90 800ms" into chips so nothing is ellipsized. */
const detailParts = computed(() => {
const raw = (props.detail || '').trim()
if (!raw || raw === '-') return []
return raw
.split(/\s*[·|]\s*/)
.map((part) => part.trim())
.filter(Boolean)
})
const stateClass = computed(() => {
if (!props.state) return 'text-gray-900 dark:text-white'
if (props.state === 'healthy') return 'text-emerald-600 dark:text-emerald-400'
if (props.state === 'warning') return 'text-amber-600 dark:text-amber-400'
if (props.state === 'critical') return 'text-red-600 dark:text-red-400'
return 'text-gray-500 dark:text-dark-400'
})
const dotClass = computed(() => {
if (props.state === 'healthy') return 'bg-emerald-500'
if (props.state === 'warning') return 'bg-amber-500'
if (props.state === 'critical') return 'bg-red-500'
return 'bg-gray-300 dark:bg-dark-600'
})
</script>
@@ -0,0 +1,122 @@
<template>
<span
class="inline-flex items-center gap-1.5 tabular-nums"
:class="rankClass"
:title="titleText"
>
<svg
v-if="showTrophy && palette"
class="rank-trophy shrink-0"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
aria-hidden="true"
>
<!-- stem -->
<path
d="M506.9 750.3c1.5 0 3-0.1 4.6-0.1 4.1 0 8.2 0.1 12.3 0.2V613.8h-16.9v136.5z"
:fill="palette.mid"
/>
<!-- base plate highlight -->
<path
d="M585.3 815.7c25.5 17.4 49 44.5 56.8 84.9h98.3c-20.4-36.7-77-71.2-155.1-84.9z"
:fill="palette.light"
/>
<!-- main cup / handles / pedestal outline -->
<path
d="M922.2 64.2H757.7c-0.2 0-0.4 0.1-0.6 0.1-0.2 0-0.4-0.1-0.6-0.1h-229c-16.3 0-29.6 13.2-29.6 29.6s13.2 29.6 29.6 29.6h199.6l0.9 215.5c0 98.3-66.1 181.4-156.1 207.4-19 5.5-39 8.5-59.8 8.5-119 0-215.9-96.9-215.9-216l-0.9-215.3h48.5c16.3 0 29.6-13.2 29.6-29.6s-13.2-29.6-29.6-29.6H100.9c-16.3 0-29.6 13.2-29.6 29.6V135c0 66.3 33.3 127.3 89.1 163.2 5 3.2 10.5 4.7 16 4.7 9.7 0 19.2-4.8 24.9-13.5 8.8-13.7 4.9-32-8.8-40.9-38.8-25-62-67.4-62-113.5v-11.6H236l0.9 215.5c0 126.7 86.2 233.7 203 265.4v151.3c-11.8 1.7-23.5 3.7-34.9 6.3-15.9 3.6-25.9 19.5-22.3 35.4 3.6 15.9 19.5 25.9 35.4 22.3 29.6-6.7 61-10.1 93.3-10.1 26.3 0 50.9 2.3 73.8 6.4 78.2 13.7 134.8 48.2 155.1 84.9H282.2c10.2-19 29.8-37.1 57.1-52.1 14.3-7.9 19.5-25.9 11.6-40.2-7.9-14.3-25.9-19.5-40.2-11.6-61.7 34-95.7 81.4-95.7 133.5 0 16.3 13.2 29.6 29.6 29.6h533.5c16.3 0 29.6-13.2 29.6-29.6 0-85.9-94.3-155.9-224.9-174.9v-151C700.3 573.1 787 465.9 787 338.7l-0.9-215.3h106.6V135c-0.1-16.3-13.4-29.6-29.7-29.6zM523.8 750.4c-4.1-0.1-8.2-0.2-12.3-0.2-1.5 0-3 0.1-4.6 0.1-2.6 0-5.2 0.1-7.8 0.2V613.8h24.7v136.6z"
:fill="palette.dark"
/>
<!-- cup face highlight -->
<path
d="M727.9 338.8l-0.9-215.5h-92.3l0.9 215.5c0 84.1-19.5 168.1-63.9 207.4 90.1-26 156.2-109.1 156.2-207.4z"
:fill="palette.light"
/>
<!-- top bar accent -->
<path
d="M433.2 123.3h1.8c16.3 0 29.6-13.2 29.6-29.6S451.4 64.2 435 64.2h-1.8c-16.3 0-29.6 13.2-29.6 29.6s13.3 29.5 29.6 29.5z"
:fill="palette.dark"
/>
</svg>
<span v-if="showTrophy" class="sr-only">{{ ariaLabel }}</span>
<span
class="text-xs font-semibold"
:class="showTrophy ? rankClass : 'text-gray-500 dark:text-dark-300'"
>
{{ label }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Rank medal palettes: light / mid / dark fills for the multi-path trophy SVG. */
const RANK_PALETTES = {
1: {
// gold
light: '#FFD524',
mid: '#F5B200',
dark: '#B45309',
},
2: {
// silver
light: '#E5E7EB',
mid: '#9CA3AF',
dark: '#4B5563',
},
3: {
// bronze
light: '#E8A86A',
mid: '#CD7F32',
dark: '#8B5A2B',
},
} as const
const props = defineProps<{
rank: number | null | undefined
}>()
const rankNum = computed(() => {
const n = Number(props.rank)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null
})
const showTrophy = computed(
() => rankNum.value != null && rankNum.value >= 1 && rankNum.value <= 3,
)
const palette = computed(() => {
if (rankNum.value === 1 || rankNum.value === 2 || rankNum.value === 3) {
return RANK_PALETTES[rankNum.value]
}
return null
})
const label = computed(() => {
// Rank 0 = present but unranked (no traffic in window / outside top list).
if (rankNum.value == null || rankNum.value <= 0) return '—'
return `#${rankNum.value}`
})
const ariaLabel = computed(() => {
if (rankNum.value == null || rankNum.value <= 0) return t('channelMonitorV2.rank.unranked')
if (rankNum.value === 1) return t('channelMonitorV2.rank.gold')
if (rankNum.value === 2) return t('channelMonitorV2.rank.silver')
if (rankNum.value === 3) return t('channelMonitorV2.rank.bronze')
return t('channelMonitorV2.rank.place', { n: rankNum.value })
})
const titleText = computed(() => ariaLabel.value)
const rankClass = computed(() => {
if (rankNum.value === 1) return 'text-amber-600 dark:text-amber-400'
if (rankNum.value === 2) return 'text-slate-500 dark:text-slate-300'
if (rankNum.value === 3) return 'text-amber-800 dark:text-amber-600'
return ''
})
</script>
@@ -0,0 +1,441 @@
<template>
<section class="mx-auto w-full max-w-6xl space-y-5 px-1 py-2 sm:px-2">
<header
class="page-header mb-0 flex flex-wrap items-center justify-between gap-3 rounded-3xl bg-white p-5 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 sm:p-6"
>
<div class="min-w-0">
<h2 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.settings.title') }}
</h2>
<p class="page-description mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('channelMonitorV2.settings.description') }}
</p>
</div>
<button
type="button"
class="btn btn-primary"
:disabled="saving || !dirty"
@click="save"
>
<Icon name="check" size="sm" />
{{ t('channelMonitorV2.settings.save') }}
</button>
</header>
<div
v-if="!systemModeV2"
class="rounded-2xl border border-amber-200 bg-amber-50/90 px-4 py-3 text-sm text-amber-900 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-100"
role="status"
>
{{
t('channelMonitorV2.settings.modeBanner', {
mode: systemModeLabel,
modeV2: t('channelMonitorV2.settings.modeV2'),
})
}}
<router-link class="ml-1 font-medium underline" to="/admin/settings">{{ t('admin.settings.tabs.features') }}</router-link>
</div>
<div
v-if="loading"
class="card flex min-h-[200px] items-center justify-center !rounded-3xl !border-0 text-sm text-gray-400 shadow-sm ring-1 ring-gray-900/5 dark:ring-dark-700"
>
<span class="animate-pulse">{{ t('channelMonitorV2.settings.loading') }}</span>
</div>
<template v-else-if="draft">
<div class="card divide-y divide-gray-100 !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:divide-dark-700 dark:!bg-dark-800 dark:ring-dark-700">
<div class="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div>
<strong class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.enableTitle') }}</strong>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.enableHint') }}
</p>
</div>
<Toggle v-model="draft.enabled" />
</div>
<div class="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div>
<strong class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.refreshTitle') }}</strong>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">{{ t('channelMonitorV2.settings.refreshHint') }}</p>
</div>
<div class="tabs inline-flex w-auto" role="group" :aria-label="t('channelMonitorV2.settings.refreshAria')">
<button
type="button"
class="tab"
:class="draft.refresh_interval_seconds === 60 ? 'tab-active' : ''"
@click="draft.refresh_interval_seconds = 60"
>
1 min
</button>
<button
type="button"
class="tab"
:class="draft.refresh_interval_seconds === 300 ? 'tab-active' : ''"
@click="draft.refresh_interval_seconds = 300"
>
5 min
</button>
</div>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.platformsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.platformsHint') }}
</p>
</div>
<div class="divide-y divide-gray-100 dark:divide-dark-700">
<div
v-for="platform in draft.platforms"
:key="platform.platform"
class="grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[auto_7rem_minmax(0,1fr)_auto]"
>
<Toggle v-model="platform.enabled" />
<strong class="text-sm font-medium text-gray-900 dark:text-white">{{ platformLabel(platform.platform) }}</strong>
<input
class="input"
:value="platform.models.join(', ')"
type="text"
:placeholder="t('channelMonitorV2.settings.modelsPlaceholder')"
@change="setModels(platform, $event)"
/>
<span
class="badge justify-self-start sm:justify-self-end"
:class="platform.models.length ? 'badge-gray' : 'badge badge-primary'"
>
{{ platform.models.length ? t('channelMonitorV2.settings.badgeOther') : t('channelMonitorV2.settings.badgeAllModels') }}
</span>
</div>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header flex flex-wrap items-center justify-between gap-2 !py-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.groupsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{
draft.group_ids.length
? t('channelMonitorV2.settings.groupsSelected', { count: draft.group_ids.length })
: t('channelMonitorV2.settings.groupsAll')
}}
</p>
</div>
<button
v-if="draft.group_ids.length"
type="button"
class="btn btn-ghost btn-sm"
@click="draft.group_ids = []"
>
{{ t('channelMonitorV2.settings.groupsAll') }}
</button>
</div>
<div class="max-h-[min(40vh,280px)] overflow-y-auto px-3 py-2 sm:px-4">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<label
v-for="group in groups"
:key="group.id"
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition hover:bg-gray-50 dark:hover:bg-dark-800/60"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/40"
:checked="draft.group_ids.includes(group.id)"
@change="toggleGroup(group.id)"
/>
<span class="min-w-0 flex-1 truncate font-medium text-gray-800 dark:text-gray-100">{{ group.name }}</span>
<small class="shrink-0 text-xs text-gray-400">{{ platformLabel(group.platform) }} · #{{ group.id }}</small>
</label>
</div>
<p v-if="groups.length === 0" class="empty-state py-8 text-sm text-gray-400">{{ t('channelMonitorV2.settings.groupsEmpty') }}</p>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.errorsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.errorsHint') }}
</p>
</div>
<div class="max-h-[min(40vh,320px)] overflow-y-auto px-3 py-2 sm:px-4">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<label
v-for="category in errorCategories"
:key="category"
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition hover:bg-gray-50 dark:hover:bg-dark-800/60"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/40"
:checked="isCategoryIgnored(category)"
@change="toggleIgnoredCategory(category)"
/>
<span class="min-w-0 flex-1 truncate font-medium text-gray-800 dark:text-gray-100">
{{ categoryLabel(category) }}
</span>
<small class="shrink-0 font-mono text-[10px] text-gray-400">{{ category }}</small>
</label>
</div>
</div>
<div class="border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{
t('channelMonitorV2.settings.ignoredSummary', {
ignored: draft.ignored_error_categories?.length || 0,
counted: countedErrorCategoryCount,
})
}}
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.healthTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.healthHint') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.minimumSample') }}</span>
<input v-model.number="draft.health_thresholds.minimum_sample" class="input" type="number" min="1" max="10000" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningError') }}</span>
<input v-model.number="warningErrorPercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalError') }}</span>
<input v-model.number="criticalErrorPercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.targetTtft') }}</span>
<input v-model.number="draft.health_thresholds.target_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningTtft') }}</span>
<input v-model.number="draft.health_thresholds.warning_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalTtft') }}</span>
<input v-model.number="draft.health_thresholds.critical_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningCache') }}</span>
<input v-model.number="warningCachePercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalCache') }}</span>
<input v-model.number="criticalCachePercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
</div>
</div>
<div class="space-y-2">
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 px-4 py-3 text-sm text-primary-900 dark:border-primary-800/50 dark:bg-primary-900/20 dark:text-primary-100">
<template v-if="namedModelCount === 0">
{{ t('channelMonitorV2.settings.namedModelsEmpty') }}
</template>
<template v-else>
{{ t('channelMonitorV2.settings.namedModelsCount', { count: namedModelCount }) }}
</template>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 text-xs text-gray-600 dark:border-dark-600 dark:bg-dark-800/50 dark:text-gray-300">
<p class="font-medium text-gray-800 dark:text-gray-100">{{ t('channelMonitorV2.settings.userContractTitle') }}</p>
<ul class="mt-1.5 list-disc space-y-0.5 pl-4">
<li>{{ t('channelMonitorV2.settings.userContract.health') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.trend') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.latency') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.models') }}</li>
</ul>
</div>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { getChannelMonitorMode, isChannelMonitorV2Mode } from '@/utils/featureFlags'
import {
getConfig,
updateConfig,
MONITOR_ERROR_CATEGORIES,
type MonitorConfig,
} from '@/api/channelMonitorV2'
import { adminAPI } from '@/api/admin'
import type { AdminGroup } from '@/types'
const { t, te } = useI18n()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const draft = ref<MonitorConfig | null>(null)
const original = ref('')
const groups = ref<AdminGroup[]>([])
const dirty = computed(() => (draft.value ? JSON.stringify(draft.value) !== original.value : false))
const namedModelCount = computed(
() => draft.value?.platforms.filter((p) => p.enabled).reduce((sum, p) => sum + p.models.length, 0) || 0
)
const errorCategories = MONITOR_ERROR_CATEGORIES
const countedErrorCategoryCount = computed(
() => errorCategories.length - (draft.value?.ignored_error_categories?.length || 0)
)
/** System settings mode must be v2 for aggregation to run; config remains editable for prep. */
const systemModeV2 = computed(() => isChannelMonitorV2Mode())
const systemModeLabel = computed(() => {
if (!appStore.cachedPublicSettings?.channel_monitor_enabled) {
return t('channelMonitorV2.settings.modeClosed')
}
return getChannelMonitorMode() === 'v1'
? t('channelMonitorV2.settings.modeV1')
: t('channelMonitorV2.settings.modeV2')
})
const defaultThresholds = {
minimum_sample: 50,
warning_error_rate: 0.05,
critical_error_rate: 0.20,
target_ttft_ms: 3000,
warning_ttft_ms: 3000,
critical_ttft_ms: 10000,
// Higher is better: below 85% watch, below 60% critical.
warning_cache_rate: 0.85,
critical_cache_rate: 0.60,
error_weight: 0.60,
ttft_weight: 0.20,
cache_weight: 0.20,
}
/** Factory ignored categories (matches backend DefaultChannelMonitorV2IgnoredErrorCategories). */
const defaultIgnoredErrorCategories = [
'authentication',
'client_cancelled',
'content_policy',
'context_limit',
'group_access',
'model_unsupported',
'not_found',
'quota_or_balance',
] as const
function percentModel(key: 'warning_error_rate' | 'critical_error_rate' | 'warning_cache_rate' | 'critical_cache_rate') {
return computed({
get: () => ((draft.value?.health_thresholds?.[key] ?? defaultThresholds[key]) * 100),
set: (value: number) => {
if (!draft.value) return
draft.value.health_thresholds[key] = Math.max(0, Math.min(100, Number(value) || 0)) / 100
},
})
}
const warningErrorPercent = percentModel('warning_error_rate')
const criticalErrorPercent = percentModel('critical_error_rate')
const warningCachePercent = percentModel('warning_cache_rate')
const criticalCachePercent = percentModel('critical_cache_rate')
function setModels(platform: MonitorConfig['platforms'][number], event: Event) {
platform.models = [
...new Set(
(event.target as HTMLInputElement).value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
),
].sort()
}
function toggleGroup(id: number) {
if (!draft.value) return
draft.value.group_ids = draft.value.group_ids.includes(id)
? draft.value.group_ids.filter((value) => value !== id)
: [...draft.value.group_ids, id].sort((a, b) => a - b)
}
function isCategoryIgnored(category: string): boolean {
return Boolean(draft.value?.ignored_error_categories?.includes(category))
}
function toggleIgnoredCategory(category: string) {
if (!draft.value) return
const current = new Set(draft.value.ignored_error_categories || [])
if (current.has(category)) current.delete(category)
else current.add(category)
draft.value.ignored_error_categories = [...current].sort()
}
function categoryLabel(category: string) {
const key = `channelMonitorV2.errorCategories.${category}`
return te(key) ? t(key) : category
}
function platformLabel(value: string) {
return (
{
anthropic: 'Claude',
openai: 'OpenAI',
grok: 'Grok',
kiro: 'Kiro',
gemini: 'Gemini',
antigravity: 'Antigravity',
composite: 'Composite',
} as Record<string, string>
)[value] || value
}
function normalizeConfig(value: MonitorConfig): MonitorConfig {
const ignored = value.ignored_error_categories
return {
...value,
health_thresholds: { ...defaultThresholds, ...(value.health_thresholds || {}) },
// Preserve explicit empty arrays from the server (operator cleared all).
ignored_error_categories: [
...(ignored == null ? [...defaultIgnoredErrorCategories] : ignored),
].sort(),
}
}
async function load() {
loading.value = true
try {
const [value, groupRows] = await Promise.all([getConfig(), adminAPI.groups.getAllIncludingInactive()])
const normalized = normalizeConfig(value)
draft.value = structuredClone(normalized)
groups.value = groupRows
original.value = JSON.stringify(normalized)
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.settings.loadFailed')))
} finally {
loading.value = false
}
}
async function save() {
if (!draft.value) return
saving.value = true
try {
const payload = normalizeConfig(draft.value)
const value = await updateConfig(payload)
const normalized = normalizeConfig(value)
draft.value = structuredClone(normalized)
original.value = JSON.stringify(normalized)
appStore.showSuccess(t('channelMonitorV2.settings.saveSuccess'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.settings.saveFailed')))
await load()
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,276 @@
<template>
<section
class="card flex min-h-[360px] flex-col overflow-hidden !rounded-3xl !border-0 !p-6 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
>
<div class="card-header mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3 !border-0 !p-0">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<span class="inline-flex h-4 w-4 text-sky-500" aria-hidden="true">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.chart.title') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.chart.description') }}
</p>
</div>
<div class="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400 sm:w-auto">
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-red-500"></span>{{ t('channelMonitorV2.chart.errorLegend') }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>{{ t('channelMonitorV2.chart.cacheLegend') }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-sky-500"></span>{{ t('channelMonitorV2.chart.ttftLegend') }}
</span>
<span class="badge badge-gray shrink-0">{{ bucketLabel }}</span>
<button
type="button"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!zoomed"
@click="resetChartZoom"
>
{{ t('channelMonitorV2.chart.resetZoom') }}
</button>
</div>
</div>
<div class="card-body min-h-0 flex-1 !p-0">
<div v-if="loading" class="flex h-[280px] items-center justify-center sm:h-[300px]">
<div class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
</div>
<div
v-else-if="chartData"
ref="chartRef"
class="h-[280px] sm:h-[300px]"
@wheel="onChartWheel"
>
<Line :data="chartData" :options="chartOptions" />
</div>
<div v-else class="flex h-[280px] items-center justify-center sm:h-[300px]">
<EmptyState
:title="t('channelMonitorV2.chart.emptyTitle')"
:description="t('channelMonitorV2.empty.description')"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, ref, watch } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
import { Line } from 'vue-chartjs'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { MonitorCoverage, MonitorMetric, MonitorHealth } from '@/api/channelMonitorV2'
import { formatMonitorMs, formatMonitorPercent } from '@/features/channel-monitor-v2/monitorFormat'
import {
applyWheelZoom,
clientXRatio,
isZoomed,
resetZoom,
sliceByZoom,
type ZoomState,
} from '@/features/channel-monitor-v2/monitorZoom'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
const { t, locale } = useI18n()
const props = defineProps<{
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
coverage: MonitorCoverage | null
loading?: boolean
}>()
const chartRef = ref<HTMLElement | null>(null)
const zoom = ref<ZoomState>(resetZoom())
const zoomed = computed(() => isZoomed(zoom.value))
const isDark = computed(() =>
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
const bucketLabel = computed(() => {
const seconds = props.coverage?.bucket_seconds || 60
const minutes = seconds / 60
if (minutes < 60) return t('channelMonitorV2.bucket.minutes', { count: minutes })
const hours = minutes / 60
if (hours < 24) return t('channelMonitorV2.bucket.hours', { count: hours })
return t('channelMonitorV2.bucket.days', { count: hours / 24 })
})
const chartData = computed(() => {
const points = visibleTrend.value
if (!points.length) return null
const labels = points.map((p) =>
new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(p.bucket_start))
)
const errorRates = smoothTrend(points.map((p) => (p.metrics.error_rate || 0) * 100))
const cacheRates = smoothTrend(points.map((p) => (p.metrics.cache_rate || 0) * 100))
const ttftP50 = smoothTrend(points.map((p) => p.metrics.ttft?.p50_ms ?? null))
return {
labels,
datasets: [
{
label: t('channelMonitorV2.chart.errorDataset'),
data: errorRates,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 67, 67, 0.10)',
yAxisID: 'yPct',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: 'origin' as const,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
},
{
label: t('channelMonitorV2.chart.cacheDataset'),
data: cacheRates,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
yAxisID: 'yPct',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: false,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
},
{
label: t('channelMonitorV2.chart.ttftDataset'),
data: ttftP50,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.08)',
yAxisID: 'yTtft',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: false,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
spanGaps: true,
},
],
}
})
/** Window the series by zoom state around the cursor — not always the last N points. */
const visibleTrend = computed(() => sliceByZoom(props.trend || [], zoom.value))
function onChartWheel(event: WheelEvent) {
// Ctrl/⌘+wheel or shift/horizontal pan; leave plain vertical wheel for page scroll.
const wantsZoom =
event.ctrlKey || event.metaKey || event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)
if (!wantsZoom) return
event.preventDefault()
const ratio = clientXRatio(event.clientX, chartRef.value)
zoom.value = applyWheelZoom(zoom.value, event, ratio)
}
function resetChartZoom() {
zoom.value = resetZoom()
}
watch(() => props.trend, () => {
zoom.value = resetZoom()
})
function smoothTrend(values: Array<number | null>): Array<number | null> {
if (values.length <= 2) return values
return values.map((value, index) => {
if (value == null) return null
const neighbors = values.slice(Math.max(0, index - 1), Math.min(values.length, index + 2))
.filter((item): item is number => item != null)
if (!neighbors.length) return value
return neighbors.reduce((sum, item) => sum + item, 0) / neighbors.length
})
}
const chartOptions = computed(() => {
const text = isDark.value ? '#9ca3af' : '#6b7280'
const grid = isDark.value ? '#374151' : '#f3f4f6'
const tooltipBg = isDark.value ? '#1f2937' : '#ffffff'
const tooltipTitle = isDark.value ? '#f3f4f6' : '#111827'
const tooltipBody = isDark.value ? '#d1d5db' : '#4b5563'
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: grid,
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label(ctx: { dataset: { label?: string }; parsed: { y: number | null } }) {
const label = ctx.dataset.label || ''
const y = ctx.parsed.y
if (y == null) return `${label}: -`
if (label === t('channelMonitorV2.chart.errorDataset') || label === t('channelMonitorV2.chart.cacheDataset')) {
return `${label}: ${formatMonitorPercent(y / 100)}`
}
return `${label}: ${formatMonitorMs(y)}`
},
},
},
},
scales: {
x: {
ticks: { color: text, maxRotation: 0, autoSkip: true, maxTicksLimit: 8, autoSkipPadding: 10, font: { size: 10 } },
grid: { display: false },
},
yPct: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
suggestedMax: 100,
ticks: {
color: text,
font: { size: 10 },
callback: (v: string | number) => `${v}%`,
},
grid: { color: grid, borderDash: [4, 4] },
title: { display: true, text: t('channelMonitorV2.chart.percentAxis'), color: text, font: { size: 11 } },
},
yTtft: {
type: 'linear' as const,
position: 'right' as const,
min: 0,
ticks: {
color: '#0ea5e9',
font: { size: 10 },
callback: (v: string | number) => formatMonitorMs(Number(v)),
},
grid: { display: false },
title: { display: true, text: t('channelMonitorV2.metrics.ttftP50'), color: '#0ea5e9', font: { size: 11 } },
},
},
}
})
</script>
@@ -0,0 +1,609 @@
<template>
<section
class="card flex min-h-[360px] flex-col overflow-visible !rounded-3xl !border-0 !p-6 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
>
<div class="card-header mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3 !border-0 !p-0">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<span class="inline-flex h-4 w-4 text-emerald-500" aria-hidden="true">
<Icon name="grid" size="sm" />
</span>
{{ t('channelMonitorV2.matrix.title') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.matrix.description') }}
</p>
</div>
<div class="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400 sm:w-auto">
<span class="badge badge-gray shrink-0">{{ bucketLabel }}</span>
<span class="hidden text-[11px] text-gray-400 dark:text-dark-400 sm:inline">{{ t('channelMonitorV2.matrix.wheelZoomX') }}</span>
<button
type="button"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!zoomed"
@click="resetMatrixZoom"
>
{{ t('channelMonitorV2.matrix.resetZoom') }}
</button>
</div>
</div>
<div class="card-body min-h-0 flex-1 !p-0">
<div
v-if="rows.length"
ref="scrollRef"
class="matrix-scroll max-h-[min(42vh,420px)] max-w-full overflow-auto rounded-2xl bg-gray-50/60 p-2 dark:bg-dark-900/30"
@wheel="onMatrixWheel"
>
<div class="matrix-table w-full" :style="tableStyle">
<div class="matrix-header matrix-row sticky top-0 z-[3] bg-gray-50 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:bg-dark-900 dark:text-gray-400">
<span>{{ t('channelMonitorV2.matrix.dimension') }}</span>
<span>{{ t('channelMonitorV2.metrics.successRate') }}</span>
<span>{{ t('channelMonitorV2.metrics.ttft') }}</span>
<span class="pulse-axis flex justify-between gap-3">
<i class="not-italic">{{ axisStart }}</i>
<i class="not-italic">{{ axisEnd }}</i>
</span>
</div>
<div
v-for="entry in alignedRows"
:key="rowKey(entry.row)"
class="matrix-row border-b border-gray-100/80 dark:border-dark-700/60"
>
<div class="dimension-cell flex min-w-0 items-center gap-2 bg-white dark:bg-dark-800" :title="rowLabel(entry.row)">
<span :class="['status-dot', cellClass(entry.row.health, entry.row.metrics.request_count)]"></span>
<strong class="truncate text-xs font-semibold text-gray-800 dark:text-gray-100">{{ rowLabel(entry.row) }}</strong>
</div>
<strong class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300">
{{ successRate(entry.row.metrics) }}
</strong>
<strong
class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300"
:title="latencyPrivacy(entry.row.metrics.ttft)"
>
{{ formatMs(entry.row.metrics.ttft.p50_ms) }}
</strong>
<div class="pulse-track grid items-stretch" :style="pulseStyle">
<span
v-for="slot in entry.slots"
:key="slot.start"
class="pulse-cell relative rounded-sm border-0 p-0 outline-offset-1"
:class="[
slot.bucket ? cellClass(slot.bucket.health, slot.bucket.metrics.request_count) : 'health-unknown',
slot.bucket ? 'has-data' : 'is-empty',
]"
tabindex="0"
role="img"
:title="slot.bucket ? bucketTooltip(slot.bucket) : t('channelMonitorV2.matrix.noTrafficAt', { time: formatBucketRange(slot.start) })"
:aria-label="slot.bucket ? bucketTooltip(slot.bucket) : t('channelMonitorV2.matrix.noTrafficAt', { time: formatBucketRange(slot.start) })"
@mouseenter="showTooltip($event, slot)"
@mousemove="moveTooltip($event)"
@mouseleave="hideTooltip"
@focus="showTooltip($event, slot)"
@blur="hideTooltip"
>
<span class="pulse-tooltip" role="tooltip">
<template v-if="slot.bucket">
<span class="pulse-tooltip-line pulse-tooltip-title">{{ formatBucketRange(slot.start) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.matrix.scoreLine', { score: formatScore(slot.bucket.health) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.successRateValue', { value: successRate(slot.bucket.metrics) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(slot.bucket.metrics.error_rate) }) }}</span>
<span v-if="showThroughput" class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.rpmValue', { value: formatRate(slot.bucket.metrics.rpm) }) }}</span>
<span v-if="showThroughput" class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.tpmValue', { value: formatRate(slot.bucket.metrics.tpm) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.ttftValue', { value: latencyPrivacy(slot.bucket.metrics.ttft) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.durationValue', { value: latencyPrivacy(slot.bucket.metrics.duration) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.cacheRateValue', { value: formatPercent(slot.bucket.metrics.cache_rate) }) }}</span>
</template>
<template v-else>
<span class="pulse-tooltip-line pulse-tooltip-title">{{ formatBucketRange(slot.start) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.matrix.noTraffic') }}</span>
</template>
</span>
</span>
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[200px] items-center justify-center py-8">
<EmptyState
:title="t('channelMonitorV2.matrix.emptyTitle')"
:description="t('channelMonitorV2.empty.description')"
/>
</div>
<div class="mt-4 flex flex-col gap-2" :aria-label="t('channelMonitorV2.matrix.legendAria')">
<div class="flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
<span class="shrink-0">{{ t('channelMonitorV2.matrix.bad') }}</span>
<div class="score-legend h-2.5 flex-1 overflow-hidden rounded-full"></div>
<span class="shrink-0">{{ t('channelMonitorV2.matrix.good') }}</span>
</div>
<div class="flex flex-wrap gap-4 text-[11px] text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score10"></i>{{ t('channelMonitorV2.matrix.healthyLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score6"></i>{{ t('channelMonitorV2.matrix.warningLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score2"></i>{{ t('channelMonitorV2.matrix.criticalLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-unknown"></i>{{ t('channelMonitorV2.matrix.unknownLegend') }}</span>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="floatingTooltip.visible"
class="matrix-floating-tooltip"
:style="{ left: `${floatingTooltip.x}px`, top: `${floatingTooltip.y}px` }"
role="tooltip"
>
<span
v-for="(line, index) in floatingTooltip.lines"
:key="`${index}:${line}`"
class="matrix-floating-tooltip-line"
:class="index === 0 ? 'matrix-floating-tooltip-title' : ''"
>
{{ line }}
</span>
</div>
</Teleport>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref, watch } from 'vue'
import type {
LatencyMetric,
MonitorCoverage,
MonitorHealth,
MonitorMatrixBucket,
MonitorMatrixRow,
MonitorMetric,
} from '@/api/channelMonitorV2'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import {
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorPercent,
formatMonitorSuccessRateFromError,
formatMonitorThroughput,
healthModeScore,
healthScoreClass,
} from '@/features/channel-monitor-v2/monitorFormat'
import {
applyWheelZoom,
clientXRatio,
isZoomed,
resetZoom,
sliceByZoom,
type ZoomState,
} from '@/features/channel-monitor-v2/monitorZoom'
type HealthMode = 'overall' | 'success' | 'ttft' | 'cache'
const { t, locale } = useI18n()
const props = withDefaults(
defineProps<{
rows: MonitorMatrixRow[]
coverage: MonitorCoverage
healthMode: HealthMode
/** When false, RPM/TPM are omitted from tooltips (user scale privacy). */
showThroughput?: boolean
}>(),
{ showThroughput: true },
)
type AlignedSlot = { start: string; bucket?: MonitorMatrixBucket }
const floatingTooltip = reactive({
visible: false,
x: 0,
y: 0,
lines: [] as string[],
})
const scrollRef = ref<HTMLElement | null>(null)
const zoom = ref<ZoomState>(resetZoom())
const zoomed = computed(() => isZoomed(zoom.value))
const allBucketStarts = computed(() => {
const step = Math.max(60, props.coverage.bucket_seconds) * 1000
const coverageStart = new Date(props.coverage.coverage_start).getTime()
const requestedStart = new Date(props.coverage.requested_start).getTime()
const end = new Date(props.coverage.data_through).getTime()
const effectiveStart = Math.max(coverageStart, requestedStart)
if (![effectiveStart, end].every(Number.isFinite) || effectiveStart >= end) return []
const starts: string[] = []
for (let cursor = Math.floor(effectiveStart / step) * step; cursor < end; cursor += step) {
starts.push(new Date(cursor).toISOString())
}
return starts
})
/** Visible bucket window after X zoom (cursor-centered), not always the tail. */
const bucketStarts = computed(() => sliceByZoom(allBucketStarts.value, zoom.value))
const tableStyle = computed(() => ({
'--bucket-count': String(Math.max(1, bucketStarts.value.length)),
minWidth: zoomed.value ? `calc(260px + ${pulseMinWidth.value})` : '0',
}))
const pulseMinWidth = computed(() => {
const count = Math.max(1, bucketStarts.value.length)
if (!zoomed.value) return '0px'
// Wider cells when more zoomed-in (smaller span)
const intensity = Math.min(8, Math.round((1 - zoom.value.span) / 0.1))
const width = 4 + intensity * 3
const gap = intensity >= 5 ? 3 : 2
return `${count * width + Math.max(0, count - 1) * gap}px`
})
const pulseStyle = computed(() => {
const count = Math.max(1, bucketStarts.value.length)
const intensity = zoomed.value ? Math.min(8, Math.round((1 - zoom.value.span) / 0.1)) : 0
const gapPx = !zoomed.value ? (count > 24 ? 1 : 2) : intensity >= 5 ? 3 : 2
const heightPx = 16
const minCell = !zoomed.value ? '0' : `${4 + intensity * 3}px`
return {
gridTemplateColumns: `repeat(${count}, minmax(${minCell}, 1fr))`,
gap: `${gapPx}px`,
height: `${heightPx}px`,
minWidth: pulseMinWidth.value,
}
})
const axisStart = computed(() =>
bucketStarts.value.length ? formatAxisTime(bucketStarts.value[0]) : '时间脉冲'
)
const axisEnd = computed(() =>
bucketStarts.value.length ? formatAxisTime(bucketStarts.value[bucketStarts.value.length - 1]) : ''
)
const bucketLabel = computed(() => {
const minutes = props.coverage.bucket_seconds / 60
if (minutes < 60) return t('channelMonitorV2.bucket.minutes', { count: minutes })
const hours = minutes / 60
if (hours < 24) return t('channelMonitorV2.bucket.hours', { count: hours })
return t('channelMonitorV2.bucket.days', { count: hours / 24 })
})
/** Shared ISO start → column index for the visible window (rebuilt when zoom/coverage changes). */
const bucketStartIndex = computed(() => {
const map = new Map<string, number>()
bucketStarts.value.forEach((start, index) => map.set(start, index))
return map
})
/** Pre-aligned sparse slots per row so wheel zoom does not rebuild Maps every paint. */
const alignedRows = computed(() => {
const starts = bucketStarts.value
const indexByStart = bucketStartIndex.value
return props.rows.map((row) => {
const slots: AlignedSlot[] = starts.map((start) => ({ start }))
for (const bucket of row.buckets || []) {
const key = new Date(bucket.bucket_start).toISOString()
const index = indexByStart.get(key)
if (index != null) slots[index] = { start: starts[index], bucket }
}
return { row, slots }
})
})
function onMatrixWheel(event: WheelEvent) {
const track = scrollRef.value
const target = event.target as HTMLElement | null
const pulse = target?.closest('.pulse-track') as HTMLElement | null
// Only zoom when over a pulse track (or Ctrl/⌘+wheel); otherwise allow page/matrix scroll.
const wantsZoom = Boolean(pulse) || event.ctrlKey || event.metaKey
if (!wantsZoom && !event.shiftKey && Math.abs(event.deltaX) <= Math.abs(event.deltaY)) {
return
}
event.preventDefault()
const ratioEl = pulse || track
const ratio = clientXRatio(event.clientX, ratioEl)
zoom.value = applyWheelZoom(zoom.value, event, ratio)
}
function resetMatrixZoom() {
zoom.value = resetZoom()
}
watch(
() => [props.coverage.coverage_start, props.coverage.data_through, props.coverage.bucket_seconds],
() => {
zoom.value = resetZoom()
},
)
function cellClass(health: MonitorHealth, requestCount: number): string {
return healthScoreClass(health, props.healthMode, requestCount)
}
function rowLabel(row: MonitorMatrixRow): string {
const parts = [row.platform]
if (row.group_name || row.group_id) parts.push(row.group_name || `#${row.group_id}`)
if (row.model) parts.push(row.model === '__other__' ? t('channelMonitorV2.otherModels') : row.model)
return parts.join(' / ')
}
function rowKey(row: MonitorMatrixRow): string {
return [row.platform, row.group_id || 0, row.model || ''].join(':')
}
function successRate(metrics: MonitorMetric): string {
// Empty traffic: no request count and no throughput signal.
// When throughput is hidden for privacy, still show success from error_rate.
const noCount = metrics.request_count <= 0
const noTP = (metrics.rpm || 0) <= 0 && (metrics.tpm || 0) <= 0
if (noCount && noTP && props.showThroughput) return '-'
return formatMonitorSuccessRateFromError(metrics.error_rate)
}
function formatScore(health: MonitorHealth): string {
const score = healthModeScore(health, props.healthMode)
if (score == null) return '—'
return `${Math.round(score)}`
}
function bucketTooltip(bucket: MonitorMatrixBucket): string {
return bucketTooltipLines(bucket).join('\n')
}
function bucketTooltipLines(bucket: MonitorMatrixBucket): string[] {
const metrics = bucket.metrics
const lines = [
formatBucketRange(bucket.bucket_start),
t('channelMonitorV2.matrix.scoreLine', { score: formatScore(bucket.health) }),
t('channelMonitorV2.metrics.successRateValue', { value: successRate(metrics) }),
t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(metrics.error_rate) }),
]
if (props.showThroughput) {
lines.push(
t('channelMonitorV2.metrics.rpmValue', { value: formatRate(metrics.rpm) }),
t('channelMonitorV2.metrics.tpmValue', { value: formatRate(metrics.tpm) }),
)
}
lines.push(
t('channelMonitorV2.metrics.ttftValue', { value: latencyPrivacy(metrics.ttft) }),
t('channelMonitorV2.metrics.durationValue', { value: latencyPrivacy(metrics.duration) }),
t('channelMonitorV2.metrics.cacheRateValue', { value: formatPercent(metrics.cache_rate) }),
)
return lines
}
function emptyTooltipLines(start: string): string[] {
return [formatBucketRange(start), t('channelMonitorV2.matrix.noTraffic')]
}
function showTooltip(event: MouseEvent | FocusEvent, slot: AlignedSlot) {
floatingTooltip.lines = slot.bucket ? bucketTooltipLines(slot.bucket) : emptyTooltipLines(slot.start)
floatingTooltip.visible = true
positionTooltip(event)
}
function moveTooltip(event: MouseEvent) {
if (!floatingTooltip.visible) return
positionTooltip(event)
}
function hideTooltip() {
floatingTooltip.visible = false
}
function positionTooltip(event: MouseEvent | FocusEvent) {
if ('clientX' in event) {
floatingTooltip.x = Math.min(window.innerWidth - 12, Math.max(12, event.clientX))
floatingTooltip.y = Math.min(window.innerHeight - 12, Math.max(12, event.clientY)) - 12
return
}
const target = event.target as HTMLElement | null
const rect = target?.getBoundingClientRect()
if (!rect) return
floatingTooltip.x = rect.left + rect.width / 2
floatingTooltip.y = rect.top - 10
}
function latencyPrivacy(metric: LatencyMetric) {
return formatLatencyPrivacy(metric.p50_ms, metric.p90_ms, metric.avg_ms, metric.p95_ms)
}
function formatPercent(value: number) {
return formatMonitorPercent(value)
}
function formatRate(value: number) {
return formatMonitorThroughput(value)
}
function formatMs(value: number | null) {
return formatMonitorMs(value)
}
function formatAxisTime(value: string) {
return new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function formatBucketRange(value: string) {
const start = new Date(value)
const end = new Date(start.getTime() + props.coverage.bucket_seconds * 1000)
return `${formatAxisTime(start.toISOString())} - ${new Intl.DateTimeFormat(locale.value || undefined, { hour: '2-digit', minute: '2-digit' }).format(end)}`
}
</script>
<style scoped>
.matrix-row {
display: grid;
grid-template-columns: minmax(120px, 1.25fr) minmax(52px, 0.36fr) minmax(62px, 0.42fr) minmax(120px, 3fr);
align-items: center;
gap: 0.5rem clamp(0.25rem, 0.8vw, 0.625rem);
min-height: 2.25rem;
}
.matrix-row > :nth-child(2) {
left: 0;
}
.matrix-row > :nth-child(3) {
left: 0;
}
.matrix-table,
.pulse-track {
min-width: 0;
}
.status-dot {
display: inline-block;
height: 0.5rem;
width: 0.5rem;
flex: none;
border-radius: 9999px;
}
/* Multi-stop green → yellow → red (score10 best … score0 worst) */
.health-score10 { background: #16a34a; }
.health-score9 { background: #22c55e; }
.health-score8 { background: #4ade80; }
.health-score7 { background: #a3e635; }
.health-score6 { background: #facc15; }
.health-score5 { background: #fbbf24; }
.health-score4 { background: #f59e0b; }
.health-score3 { background: #f97316; }
.health-score2 { background: #fb7185; }
.health-score1 { background: #f87171; }
.health-score0 { background: rgb(239, 67, 67); }
/* Coarse fallbacks (older payloads without score) */
.health-healthy { background: #22c55e; }
.health-warning { background: #f59e0b; }
.health-critical { background: #ef4444; }
.health-unknown { background: #9ca3af; }
.score-legend {
background: linear-gradient(
90deg,
rgb(239, 67, 67) 0%,
#f87171 15%,
#f97316 30%,
#f59e0b 45%,
#facc15 55%,
#a3e635 70%,
#22c55e 85%,
#16a34a 100%
);
}
.pulse-cell {
position: relative;
min-width: 0;
}
.pulse-cell.has-data {
cursor: help;
}
.pulse-cell.is-empty {
opacity: 0.55;
cursor: default;
}
.pulse-cell.has-data:hover,
.pulse-cell.has-data:focus-visible {
outline: 2px solid rgb(var(--color-primary-500, 99 102 241) / 0.55);
outline-offset: 1px;
z-index: 5;
}
/* CSS-only hover tooltip — no click modal, no absolute request counts.
Native title is also provided so dense/scrolling layouts can always show the
full content even when a browser clips transformed children. */
.pulse-tooltip {
pointer-events: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
z-index: 40;
min-width: 11.5rem;
max-width: 16rem;
transform: translateX(-50%) translateY(4px);
border-radius: 0.75rem;
border: 1px solid rgb(229 231 235);
background: rgb(255 255 255);
padding: 0.5rem 0.625rem;
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15);
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
white-space: nowrap;
}
:global(.dark) .pulse-tooltip {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
color: rgb(229 231 235);
}
.pulse-tooltip-line {
display: block;
font-size: 11px;
line-height: 1.45;
color: rgb(75 85 99);
}
:global(.dark) .pulse-tooltip-line {
color: rgb(209 213 219);
}
.pulse-tooltip-title {
margin-bottom: 0.2rem;
font-weight: 600;
color: rgb(17 24 39);
}
:global(.dark) .pulse-tooltip-title {
color: rgb(243 244 246);
}
.pulse-cell:hover .pulse-tooltip,
.pulse-cell:focus-visible .pulse-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* Keep semantic/test text in-cell, but render the visible tooltip through body
Teleport so it cannot be clipped by the matrix viewport. */
.pulse-tooltip {
display: none;
}
.matrix-floating-tooltip {
pointer-events: none;
position: fixed;
z-index: 9999;
min-width: 11.5rem;
max-width: min(18rem, calc(100vw - 1.5rem));
transform: translate(-50%, -100%);
border-radius: 0.75rem;
border: 1px solid rgb(229 231 235);
background: rgb(255 255 255);
padding: 0.5rem 0.625rem;
box-shadow: 0 18px 40px -12px rgb(0 0 0 / 0.28);
white-space: nowrap;
}
:global(.dark) .matrix-floating-tooltip {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
color: rgb(229 231 235);
}
.matrix-floating-tooltip-line {
display: block;
font-size: 11px;
line-height: 1.45;
color: rgb(75 85 99);
}
:global(.dark) .matrix-floating-tooltip-line {
color: rgb(209 213 219);
}
.matrix-floating-tooltip-title {
margin-bottom: 0.2rem;
font-weight: 600;
color: rgb(17 24 39);
}
:global(.dark) .matrix-floating-tooltip-title {
color: rgb(243 244 246);
}
@media (max-width: 640px) {
.matrix-row {
grid-template-columns: minmax(88px, 1fr) minmax(48px, 0.45fr) minmax(54px, 0.5fr) minmax(96px, 2.6fr);
gap: 0.35rem;
}
.matrix-row > :nth-child(2) {
left: 0;
}
.matrix-row > :nth-child(3) {
left: 0;
}
}
</style>
@@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MetricCell from '../MetricCell.vue'
describe('MetricCell', () => {
it('renders label/value/detail through shipped template with design-system classes', () => {
const wrapper = mount(MetricCell, {
props: {
label: '请求',
value: '1,234',
detail: '12.5 RPM',
state: 'healthy',
},
})
expect(wrapper.classes().join(' ') + wrapper.html()).toContain('stat-card')
expect(wrapper.text()).toContain('请求')
expect(wrapper.text()).toContain('1,234')
expect(wrapper.text()).toContain('12.5 RPM')
expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/)
})
it('maps warning and critical health states to distinct colors', () => {
const warning = mount(MetricCell, {
props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' },
})
expect(warning.find('strong').classes().join(' ')).toMatch(/amber/)
const critical = mount(MetricCell, {
props: { label: '错误', value: '50%', detail: '5 次', state: 'critical' },
})
expect(critical.find('strong').classes().join(' ')).toMatch(/red/)
})
it('renders multi-part detail as non-truncated chips (AVG · P90 fully visible)', () => {
const wrapper = mount(MetricCell, {
props: {
label: '首 Token P50',
value: '400ms',
detail: 'AVG 475ms · P90 800ms',
state: 'healthy',
},
})
expect(wrapper.find('small.truncate').exists()).toBe(false)
expect(wrapper.find('.whitespace-nowrap').exists()).toBe(true)
expect(wrapper.text()).toContain('AVG 475ms')
expect(wrapper.text()).toContain('P90 800ms')
expect(wrapper.text()).not.toContain('…')
})
})
@@ -0,0 +1,132 @@
const i18nT = (key: string, params?: Record<string, unknown>) => {
const map: Record<string, string> = {
'channelMonitorV2.matrix.title': '色块矩阵',
'channelMonitorV2.matrix.description': '趋势色块视图',
'channelMonitorV2.bucket.minutes': '{count}分钟',
'channelMonitorV2.matrix.wheelZoomX': '滚轮缩放横向',
'channelMonitorV2.matrix.resetZoom': '重置缩放',
'channelMonitorV2.matrix.dimension': '维度',
'channelMonitorV2.metrics.successRate': '成功率',
'channelMonitorV2.metrics.ttft': '首 Token',
'channelMonitorV2.matrix.scoreLine': '评分 {score}',
'channelMonitorV2.metrics.successRateValue': '成功率 {value}',
'channelMonitorV2.metrics.errorRateValue': '错误率 {value}',
'channelMonitorV2.metrics.rpmValue': 'RPM {value}',
'channelMonitorV2.metrics.tpmValue': 'TPM {value}',
'channelMonitorV2.metrics.ttftValue': '首 Token {value}',
'channelMonitorV2.metrics.durationValue': '时长 {value}',
'channelMonitorV2.metrics.cacheRateValue': '缓存率 {value}',
'channelMonitorV2.matrix.noTrafficAt': '{time} 无流量',
'channelMonitorV2.matrix.noTraffic': '无流量',
'channelMonitorV2.matrix.legendAria': '图例',
'channelMonitorV2.matrix.bad': '差',
'channelMonitorV2.matrix.good': '好',
'channelMonitorV2.matrix.healthyLegend': '健康',
'channelMonitorV2.matrix.warningLegend': '警告',
'channelMonitorV2.matrix.criticalLegend': '异常',
'channelMonitorV2.matrix.unknownLegend': '未知',
}
const template = map[key] || key
return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? ''))
}
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: i18nT,
te: (key: string) => key.startsWith('channelMonitorV2.'),
locale: { value: 'zh' },
}),
}
})
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import RelayPulseMatrix from '../RelayPulseMatrix.vue'
import type { MonitorHealth, MonitorMetric } from '@/api/channelMonitorV2'
const health: MonitorHealth = {
overall: 'warning',
error_rate: 'warning',
ttft: 'healthy',
cache: 'warning',
score: 52,
error_rate_score: 40,
ttft_score: 100,
cache_score: 50,
minimum_sample: 20,
}
function metrics(requestCount: number): MonitorMetric {
return {
success_requests: requestCount ? requestCount - 1 : 0,
error_requests: requestCount ? 1 : 0,
request_count: requestCount,
token_count: 100,
rpm: 1.5,
tpm: 10.2,
error_rate: requestCount ? 1 / requestCount : 0,
cache_rate: 0.5,
cache_rate_numerator: 50,
cache_rate_denominator: 100,
ttft: { sample_count: requestCount, p50_ms: 100, p95_ms: 300, avg_ms: 150 },
duration: { sample_count: requestCount, p50_ms: 500, p95_ms: 900, avg_ms: 600 },
upstream_affected_requests: 2,
upstream_attempt_count: 3,
}
}
describe('RelayPulseMatrix', () => {
it('shows privacy-safe hover tooltips and multi-band colors without click modal', async () => {
const wrapper = mount(RelayPulseMatrix, {
props: {
rows: [{
platform: 'openai',
group_id: 7,
group_name: '默认组',
model: 'gpt-5',
metrics: metrics(10),
health,
buckets: [
{ bucket_start: '2026-08-01T00:00:00Z', metrics: metrics(10), health },
{ bucket_start: '2026-08-01T00:01:00Z', metrics: metrics(0), health },
],
}],
coverage: {
requested_start: '2026-08-01T00:00:00Z',
coverage_start: '2026-08-01T00:00:00Z',
data_through: '2026-08-01T00:03:00Z',
computed_at: '2026-08-01T00:03:00Z',
aggregation_lag_seconds: 0,
coverage_complete: true,
bucket_seconds: 60,
},
healthMode: 'overall',
},
})
const cells = wrapper.findAll('.pulse-cell')
expect(cells).toHaveLength(3)
// Hover tooltip content (privacy-safe: no absolute request/error counts)
const tip = cells[0].text()
expect(tip).toContain('成功率')
expect(tip).toContain('90.0%')
expect(tip).toContain('RPM')
expect(tip).toContain('TPM')
expect(tip).toContain('首 Token')
expect(tip).not.toContain('请求数')
expect(tip).not.toContain('用户错误')
expect(tip).not.toContain('上游受影响')
// Multi-band class from score 52 → score5
expect(cells[0].classes().some((c) => c.startsWith('health-score'))).toBe(true)
// Redacted user payloads may have request_count=0 but still include score.
expect(cells[1].classes().some((c) => c.startsWith('health-score'))).toBe(true)
expect(cells[2].classes()).toContain('health-unknown')
// No click-to-open modal
await cells[0].trigger('click')
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
})
@@ -0,0 +1,104 @@
/**
* Structure contracts: channel-monitor-v2 + studio shells must use project
* design-system utility classes rather than isolated flat RGB skins.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(__dirname, '../../..')
function read(rel: string) {
return readFileSync(resolve(root, rel), 'utf8')
}
describe('channel-monitor-v2 design system structure', () => {
it('user ChannelStatus V2 shell uses page-header, card, btn, tabs utilities', () => {
// Route wrapper may switch V1/V2; design chrome lives on the V2 implementation.
const src = read('views/user/ChannelStatusV2View.vue')
expect(src).toContain('page-header')
expect(src).toContain('page-title')
expect(src).toContain('class="card')
expect(src).toContain('btn btn-secondary')
expect(src).toContain('class="tab')
expect(src).toContain('tab-active')
expect(src).toContain('badge badge-warning')
// Compact single-row toolbar
expect(src).toContain('monitor-toolbar')
expect(src).toContain('clearFilters')
expect(src).toContain('healthModeOptions')
expect(src).toContain("'cache'")
// Ops elevation: rounded-3xl + ring surfaces
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
// Overview-first KPI strip before primary viz
expect(src.indexOf('summaryAria')).toBeLessThan(src.indexOf('MonitorTrendChart'))
// No page-level fixed min-width that forces viewport horizontal scroll
expect(src).not.toMatch(/min-width:\s*980px/)
expect(src).not.toMatch(/min-w-\[980px\]/)
// Dense tables scroll internally
expect(src).toMatch(/max-h-\[min\(52vh/)
expect(src).toContain('overflow-auto')
// Trend view toggle (pulse matrix / line chart) + default platform/group dimension
expect(src).toContain("trendView")
expect(src).toContain("'platform_group'")
expect(src).toContain('MonitorTrendChart')
})
it('RelayPulseMatrix uses card chrome, matrix scroll, and hover tooltips (no click modal)', () => {
const src = read('features/channel-monitor-v2/RelayPulseMatrix.vue')
expect(src).toContain('class="card')
expect(src).toContain('card-header')
expect(src).toContain('card-body')
expect(src).toContain('matrix-scroll')
expect(src).toMatch(/max-h-\[min\(42vh/)
expect(src).toContain('overflow-auto')
expect(src).toContain('pulse-tooltip')
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
expect(src).not.toContain('modal-overlay')
expect(src).not.toContain('modal-content')
})
it('MetricCell uses stat-card utility', () => {
const src = read('features/channel-monitor-v2/MetricCell.vue')
expect(src).toContain('stat-card')
expect(src).toContain('stat-label')
expect(src).toContain('stat-value')
expect(src).toContain('rounded-3xl')
})
it('MonitorTrendChart uses Ops chart shell tokens', () => {
const src = read('features/channel-monitor-v2/MonitorTrendChart.vue')
expect(src).toContain('class="card')
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
expect(src).toContain('EmptyState')
expect(src).toContain('min-h-[360px]')
})
it('FilterMultiSelect uses rounded-xl input chrome and dropdown utility', () => {
const src = read('features/channel-monitor-v2/FilterMultiSelect.vue')
expect(src).toContain('rounded-xl')
expect(src).toContain('dropdown')
expect(src).toContain('dropdown-item')
})
it('MonitorSettingsPanel uses page-header, card, btn-primary, tabs', () => {
const src = read('features/channel-monitor-v2/MonitorSettingsPanel.vue')
expect(src).toContain('page-header')
expect(src).toContain('btn btn-primary')
expect(src).toContain('class="card')
expect(src).toContain('tab-active')
expect(src).toMatch(/max-h-\[min\(40vh/)
})
it('admin ChannelMonitorView V2 tab chrome uses project tabs', () => {
const src = read('views/admin/ChannelMonitorView.vue')
expect(src).toContain('page-header')
expect(src).toContain('page-title')
expect(src).toContain('class="tabs')
expect(src).toContain('tab-active')
expect(src).toContain('MonitorSettingsPanel')
})
})
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import {
formatLatencyKpiSecondary,
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorNumber,
formatMonitorPercent,
formatMonitorRate,
formatMonitorSuccessRate,
formatMonitorSuccessRateFromError,
formatMonitorThroughput,
healthScoreClass,
healthStateClass,
scoreToBand,
} from '../monitorFormat'
import type { MonitorHealth } from '@/api/channelMonitorV2'
describe('monitorFormat accuracy', () => {
it('formats request/token counts with compact notation above threshold', () => {
expect(formatMonitorNumber(10)).toBe('10')
expect(formatMonitorNumber(9999)).toBe('9,999')
// compact at >= 10000
expect(formatMonitorNumber(10000)).toMatch(/1/)
expect(formatMonitorNumber(0)).toBe('0')
})
it('formats rates and percents from raw API fractions without hardcoding pretty values', () => {
expect(formatMonitorRate(12.56)).toBe('12.6')
expect(formatMonitorPercent(0.1)).toBe('10.0%')
expect(formatMonitorPercent(0.005)).toBe('0.50%')
// value < 0.01 uses 2 decimal places (includes exact 0)
expect(formatMonitorPercent(0)).toBe('0.00%')
expect(formatMonitorPercent(0.5)).toBe('50.0%')
})
it('formats RPM/TPM throughput with K/M compact notation', () => {
expect(formatMonitorThroughput(12.56)).toBe('12.6')
expect(formatMonitorThroughput(999)).toMatch(/999/)
expect(formatMonitorThroughput(1000)).toBe('1.0K')
expect(formatMonitorThroughput(1500)).toBe('1.5K')
expect(formatMonitorThroughput(1_000_000)).toBe('1.0M')
expect(formatMonitorThroughput(1_500_000)).toBe('1.5M')
expect(formatMonitorThroughput(0)).toBe('0')
})
it('formats latency ms/s boundaries from API fields', () => {
expect(formatMonitorMs(null)).toBe('-')
expect(formatMonitorMs(300)).toBe('300ms')
expect(formatMonitorMs(1500)).toBe('1.5s')
expect(formatMonitorMs(999)).toBe('999ms')
})
it('formats KPI secondary latency as AVG · P90 (P50 is primary)', () => {
expect(formatLatencyKpiSecondary(150, 400)).toBe('AVG 150ms · P90 400ms')
expect(formatLatencyKpiSecondary(1500, null, 2000)).toBe('AVG 1.5s · P95 2.0s')
expect(formatLatencyKpiSecondary(null, null)).toBe('-')
})
it('computes success rate from success_requests / request_count', () => {
expect(formatMonitorSuccessRate(9, 10)).toBe('90.0%')
expect(formatMonitorSuccessRate(0, 0)).toBe('-')
expect(formatMonitorSuccessRate(1, 3)).toBe('33.3%')
})
it('derives success rate from error_rate without absolute counts', () => {
expect(formatMonitorSuccessRateFromError(0.1)).toBe('90.0%')
expect(formatMonitorSuccessRateFromError(0)).toBe('100.0%')
})
it('maps continuous scores to multi-stop bands', () => {
expect(scoreToBand(100)).toBe('score10')
expect(scoreToBand(95)).toBe('score10')
expect(scoreToBand(80)).toBe('score8')
expect(scoreToBand(50)).toBe('score5')
expect(scoreToBand(0)).toBe('score0')
expect(scoreToBand(null)).toBe('unknown')
})
it('builds score-based health classes for matrix cells', () => {
const health: MonitorHealth = {
overall: 'warning',
error_rate: 'warning',
ttft: 'healthy',
cache: 'warning',
score: 52,
error_rate_score: 40,
ttft_score: 100,
cache_score: 50,
minimum_sample: 20,
}
expect(healthScoreClass(health, 'overall', 10)).toBe('health-score5')
expect(healthScoreClass(health, 'success', 10)).toBe('health-score4')
expect(healthScoreClass(health, 'ttft', 10)).toBe('health-score10')
expect(healthScoreClass(health, 'cache', 10)).toBe('health-score5')
// Redacted user payloads have request_count=0 but keep score fields.
expect(healthScoreClass(health, 'overall', 0)).toBe('health-score5')
expect(healthScoreClass({ ...health, score: null }, 'overall', 0)).toBe('health-unknown')
})
it('maps health states for status dots', () => {
expect(healthStateClass('healthy')).toBe('health-healthy')
expect(healthStateClass(undefined)).toBe('health-unknown')
})
it('formats privacy-safe latency lines with avg/p50/p90', () => {
expect(formatLatencyPrivacy(100, 250, 120, 300)).toBe('AVG 120ms · P50 100ms · P90 250ms')
expect(formatLatencyPrivacy(100, null, null, 300)).toBe('P50 100ms · P95 300ms')
expect(formatLatencyPrivacy(null, null)).toBe('-')
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ZOOM,
MIN_ZOOM_SPAN,
applyWheelZoom,
clampZoom,
isZoomed,
resetZoom,
sliceByZoom,
} from '../monitorZoom'
describe('monitorZoom', () => {
it('resets to full span', () => {
expect(resetZoom()).toEqual(DEFAULT_ZOOM)
expect(isZoomed(DEFAULT_ZOOM)).toBe(false)
})
it('zooms in around cursor without always pinning to the end', () => {
const mid = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0.5)
expect(mid.span).toBeLessThan(1)
expect(mid.span).toBeGreaterThanOrEqual(MIN_ZOOM_SPAN)
// Center zoom keeps window roughly centered
expect(mid.offset).toBeGreaterThan(0)
expect(mid.offset + mid.span).toBeLessThanOrEqual(1.001)
const left = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0)
expect(left.offset).toBeLessThanOrEqual(0.001)
const right = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 1)
expect(right.offset + right.span).toBeGreaterThan(0.99)
})
it('pans with shift+wheel', () => {
const zoomed = clampZoom({ span: 0.4, offset: 0.1 })
const panned = applyWheelZoom(zoomed, { deltaY: 200, deltaX: 0, shiftKey: true }, 0.5)
expect(panned.span).toBeCloseTo(zoomed.span, 5)
expect(panned.offset).toBeGreaterThan(zoomed.offset)
})
it('clamps span to MIN_ZOOM_SPAN and keeps window in range', () => {
const tight = clampZoom({ span: 0.01, offset: 0.9 })
expect(tight.span).toBe(MIN_ZOOM_SPAN)
expect(tight.offset + tight.span).toBeLessThanOrEqual(1.001)
})
it('slices series by window, not always last N points', () => {
const items = Array.from({ length: 100 }, (_, i) => i)
const startWindow = sliceByZoom(items, { span: 0.2, offset: 0 })
expect(startWindow[0]).toBe(0)
expect(startWindow.length).toBe(20)
const midWindow = sliceByZoom(items, { span: 0.2, offset: 0.4 })
expect(midWindow[0]).toBe(40)
expect(midWindow[midWindow.length - 1]).toBe(59)
const full = sliceByZoom(items, DEFAULT_ZOOM)
expect(full).toHaveLength(100)
})
})
@@ -0,0 +1,160 @@
/**
* Shared display formatters for channel-monitor-v2.
* Kept pure so unit tests can lock metric presentation accuracy.
*
* Privacy: prefer rates (error_rate, RPM, TPM, success %) over absolute
* request/error/token counts in user-facing surfaces.
*/
import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2'
import { formatCompactNumber } from '@/utils/format'
export function monitorIntlLocale(): string {
if (typeof document !== 'undefined') {
const htmlLang = document.documentElement.getAttribute('lang')?.trim()
if (htmlLang) return htmlLang
}
if (typeof navigator !== 'undefined' && navigator.language) return navigator.language
return 'zh-CN'
}
export function formatMonitorNumber(value: number, compactAt = 10000, locale = monitorIntlLocale()): string {
return Intl.NumberFormat(locale, {
notation: value >= compactAt ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value || 0)
}
export function formatMonitorRate(value: number, locale = monitorIntlLocale()): string {
return Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value || 0)
}
/**
* RPM/TPM throughput display: always K/M compact (1.0K, 1.5M) for ops-readable density.
* Prefer this over formatMonitorRate for rate KPIs and table cells.
*/
export function formatMonitorThroughput(value: number | null | undefined): string {
if (value == null || Number.isNaN(Number(value))) return '0'
const n = Number(value)
if (Math.abs(n) < 1000) {
return Intl.NumberFormat(monitorIntlLocale(), { maximumFractionDigits: 1 }).format(n)
}
return formatCompactNumber(n)
}
export function formatMonitorPercent(value: number, locale = monitorIntlLocale()): string {
return `${new Intl.NumberFormat(locale, {
minimumFractionDigits: value < 0.01 ? 2 : 1,
maximumFractionDigits: value < 0.01 ? 2 : 1,
}).format((value || 0) * 100)}%`
}
export function formatMonitorMs(value: number | null | undefined): string {
if (value == null) return '-'
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms`
}
export function formatMonitorSuccessRate(successRequests: number, requestCount: number): string {
if (!requestCount) return '-'
return formatMonitorPercent(successRequests / requestCount)
}
export function formatMonitorSuccessRateFromError(errorRate: number): string {
return formatMonitorPercent(1 - (errorRate || 0))
}
/**
* Map continuous 0–100 score to 11 fine bands for multi-stop green→yellow→red.
* score10 = best (green), score0 = worst (red).
*/
export function scoreToBand(score: number | null | undefined): HealthScoreBand {
if (score == null || Number.isNaN(score)) return 'unknown'
const clamped = Math.max(0, Math.min(100, score))
// 0–100 → score0..score10 (11 stops for green→yellow→red gradient)
const band = Math.round(clamped / 10)
return `score${Math.max(0, Math.min(10, band))}` as HealthScoreBand
}
export type HealthDisplayMode = 'overall' | 'success' | 'ttft' | 'cache'
/** Resolve the score used for a health mode. */
export function healthModeScore(
health: MonitorHealth,
mode: HealthDisplayMode,
): number | null {
if (mode === 'success') {
return health.error_rate_score ?? null
}
if (mode === 'ttft') {
return health.ttft_score ?? null
}
if (mode === 'cache') {
return health.cache_score ?? null
}
return health.score ?? null
}
export function healthScoreClass(
health: MonitorHealth,
mode: HealthDisplayMode,
requestCount: number,
): string {
const score = healthModeScore(health, mode)
if (score == null) {
if (requestCount <= 0) return 'health-unknown'
// Fall back to coarse state when score is absent (older payloads).
const coarse =
mode === 'success'
? health.error_rate
: mode === 'ttft'
? health.ttft
: mode === 'cache'
? health.cache
: health.overall
return healthStateClass(coarse)
}
return `health-${scoreToBand(score)}`
}
export function healthStateClass(state: string | undefined): string {
return `health-${state || 'unknown'}`
}
/** Privacy-safe latency summary: avg + p50 + p90 (no absolute sample counts). */
export function formatLatencyPrivacy(
p50: number | null | undefined,
p90: number | null | undefined,
avg?: number | null | undefined,
p95?: number | null | undefined,
): string {
const parts: string[] = []
if (avg != null) parts.push(`AVG ${formatMonitorMs(avg)}`)
if (p50 != null) parts.push(`P50 ${formatMonitorMs(p50)}`)
if (p90 != null) parts.push(`P90 ${formatMonitorMs(p90)}`)
// p95 only as fallback when p90 missing (older payloads)
if (p90 == null && p95 != null) parts.push(`P95 ${formatMonitorMs(p95)}`)
return parts.length ? parts.join(' · ') : '-'
}
/**
* KPI secondary line for latency: AVG + P90 only (P50 is the primary value).
* Falls back to P95 when P90 is absent. Delimiter is " · " so MetricCell can
* split into non-truncated chips.
*/
export function formatLatencyKpiSecondary(
avg?: number | null | undefined,
p90?: number | null | undefined,
p95?: number | null | undefined,
): string {
const parts: string[] = []
if (avg != null && Number.isFinite(avg)) parts.push(`AVG ${formatMonitorMs(avg)}`)
if (p90 != null && Number.isFinite(p90)) parts.push(`P90 ${formatMonitorMs(p90)}`)
else if (p95 != null && Number.isFinite(p95)) parts.push(`P95 ${formatMonitorMs(p95)}`)
return parts.length ? parts.join(' · ') : '-'
}
/** Fallback when i18n key is missing; prefer `channelMonitorV2.errorCategories.*`. */
export function monitorErrorCategoryLabel(category: string): string {
return category
}
@@ -0,0 +1,103 @@
/**
* Pure X-axis zoom/pan for channel-monitor pulse matrix and line chart.
* No chartjs-plugin-zoom dependency — state is span + offset fractions.
*/
export interface ZoomState {
/** Visible fraction of the full series (1 = full range). */
span: number
/** Start of the visible window as a fraction of the full domain [0, 1 - span]. */
offset: number
}
export const DEFAULT_ZOOM: ZoomState = { span: 1, offset: 0 }
export const MIN_ZOOM_SPAN = 0.12
const ZOOM_STEP = 0.12
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function resetZoom(): ZoomState {
return { ...DEFAULT_ZOOM }
}
export function isZoomed(state: ZoomState): boolean {
return state.span < 0.999 || state.offset > 0.001
}
/** Normalize offset so the visible window stays within [0, 1]. */
export function clampZoom(state: ZoomState): ZoomState {
const span = clamp(state.span, MIN_ZOOM_SPAN, 1)
const maxOffset = Math.max(0, 1 - span)
return {
span,
offset: clamp(state.offset, 0, maxOffset),
}
}
/**
* Wheel zoom around cursor xRatio (0–1 within the chart/matrix track).
* Shift+wheel or horizontal delta pans offset instead.
*/
export function applyWheelZoom(
state: ZoomState,
event: Pick<WheelEvent, 'deltaY' | 'deltaX' | 'shiftKey'>,
xRatio: number,
): ZoomState {
const ratio = clamp(Number.isFinite(xRatio) ? xRatio : 0.5, 0, 1)
const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)
if (isPan) {
if (state.span >= 1) return clampZoom(state)
const delta = event.shiftKey ? event.deltaY : event.deltaX
// Positive delta → pan right (increase offset)
const panFraction = (delta / 400) * state.span
return clampZoom({
span: state.span,
offset: state.offset + panFraction,
})
}
// Zoom in (deltaY < 0) / out (deltaY > 0) around cursor
const direction = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0
if (direction === 0) return clampZoom(state)
const nextSpan = clamp(state.span * (1 + direction * ZOOM_STEP), MIN_ZOOM_SPAN, 1)
if (Math.abs(nextSpan - state.span) < 1e-9) return clampZoom(state)
// Keep the point under the cursor fixed in data space.
// windowStart is offset (domain fraction); dataPos = windowStart + ratio * span
const windowStart = state.offset
const dataPos = windowStart + ratio * state.span
const nextStart = dataPos - ratio * nextSpan
return clampZoom({
span: nextSpan,
offset: nextStart,
})
}
/**
* Slice an array using zoom state. Cursor-centered windowing — not always "last N".
*/
export function sliceByZoom<T>(items: readonly T[], state: ZoomState): T[] {
if (!items.length) return []
const zoom = clampZoom(state)
if (zoom.span >= 0.999) return [...items]
const count = Math.max(1, Math.ceil(items.length * zoom.span))
const maxStart = Math.max(0, items.length - count)
// offset is window start in [0, 1-span] domain → map to index
const start = Math.min(maxStart, Math.max(0, Math.round(zoom.offset * items.length)))
return items.slice(start, start + count)
}
/** Map a client X position within an element to 0–1 ratio. */
export function clientXRatio(clientX: number, element: HTMLElement | null | undefined): number {
if (!element) return 0.5
const rect = element.getBoundingClientRect()
if (rect.width <= 0) return 0.5
return clamp((clientX - rect.left) / rect.width, 0, 1)
}
@@ -0,0 +1,121 @@
/** Channel Monitor V2 (user + admin passive monitor UI) */
export default {
channelMonitorV2: {
title: 'Channel Monitor',
updating: 'Updating data',
updatedTo: 'Updated to {time}',
partialCoverage: 'Partial historical coverage',
timeRange: 'Time range',
clearFilters: 'Reset',
refreshingFilters: 'Filters changed; refreshing matrix, trend, and details…',
switchingData: 'Switching filtered data…',
summaryAria: 'Selected range summary',
loadFailed: 'Failed to load channel monitor',
detailLoadFailed: 'Failed to load channel monitor details',
otherModels: 'Other models',
ignored: 'Ignored',
currentUser: 'Current user',
ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' },
filters: {
platform: 'Platform', allPlatforms: 'All', group: 'Group', allGroups: 'All', model: 'Model', allModels: 'All',
empty: 'No options', selectedCount: '{count}', labelValue: '{label}: {value}'
},
groupBy: {
label: 'Group by', platform: 'Platform', platformGroup: 'Platform / Group', platformModel: 'Platform / Model', platformGroupModel: 'Platform / Group / Model'
},
trendView: { label: 'Trend view', pulse: 'Pulse matrix', line: 'Line chart' },
healthMode: { label: 'Health display', overall: 'Overall', success: 'Error rate', ttft: 'First token', cache: 'Cache rate' },
tabs: { aria: 'Detail dimension', models: 'Models', errors: 'Error reasons', users: 'User ranking' },
metrics: {
rpm: 'RPM', tpm: 'TPM', rpmDetail: 'Request rate', tpmDetail: 'Token rate', errorRate: 'Error rate', ttft: 'First token', ttftP50: 'First token P50', durationP50: 'Duration P50', cacheRate: 'Cache rate', cacheDetail: 'Read cache share', successRate: 'Success rate',
successRateValue: 'Success rate {value}', errorRateValue: 'Error rate {value}', rpmValue: 'RPM {value}', tpmValue: 'TPM {value}', ttftValue: 'First token {value}', durationValue: 'Duration {value}', cacheRateValue: 'Cache rate {value}'
},
table: { platformModel: 'Platform / Model', rank: 'Rank', user: 'User' },
empty: { title: 'No data to display', description: 'Try changing the time range or filters' },
bucket: { minutes: '{count}-minute buckets', hours: '{count}-hour buckets', days: '{count}-day buckets' },
matrix: {
title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Wheel over blocks to zoom', wheelZoomX: 'Wheel over blocks to zoom horizontally', dimension: 'Channel dimension', emptyTitle: 'No matrix data for the selected window', legendAria: 'Health score legend', bad: 'Bad', good: 'Good', healthyLegend: 'Healthy (≥80)', warningLegend: 'Watch (50–79)', criticalLegend: 'Critical (<50)', unknownLegend: 'No traffic / insufficient samples', noTraffic: 'No traffic in this interval', noTrafficAt: '{time} · no traffic', scoreLine: 'Health score {score}', resetZoom: 'Reset zoom'
},
chart: {
title: 'Availability trend', description: 'Smoothed trend: error rate · first token P50 · cache rate', emptyTitle: 'No trend data for the selected window', errorLegend: 'Error rate (left axis %)', cacheLegend: 'Cache rate (left axis %)', ttftLegend: 'First token P50 (right axis)', errorDataset: 'Error rate trend %', cacheDataset: 'Cache rate trend %', ttftDataset: 'First token trend P50 (ms)', percentAxis: 'Rate %', resetZoom: 'Reset zoom'
},
errorDetail: { http: 'HTTP {code}', upstream: 'Upstream {code}', noMessage: 'No error message', empty: 'Category rates only (sample messages are admin-only)' },
errorCategories: {
content_policy: 'Content policy', authentication: 'Authentication', context_limit: 'Context limit', invalid_request: 'Invalid request', model_unsupported: 'Unsupported model', group_access: 'Group access', quota_or_balance: 'Quota or balance', account_pool_unavailable: 'Account pool unavailable', rate_or_capacity: 'Rate or capacity', timeout: 'Timeout', transport_or_stream: 'Transport or stream', upstream_forbidden: 'Upstream forbidden', not_found: 'Not found', client_cancelled: 'Client cancelled', upstream_5xx: 'Upstream 5xx', internal: 'Internal', other: 'Other'
},
rank: {
gold: 'Rank 1 gold',
silver: 'Rank 2 silver',
bronze: 'Rank 3 bronze',
place: 'Rank {n}',
unranked: 'Unranked',
},
settings: {
title: 'V2 data monitor config',
description:
'Configure passive usage aggregation dimensions (platform / model / group) and refresh cadence. Health colors and details on the user /monitor page show rates, RPM, and TPM — not absolute request volume.',
save: 'Save',
loading: 'Loading…',
loadFailed: 'Failed to load V2 config',
saveSuccess: 'V2 monitor config saved',
saveFailed: 'Failed to save V2 config',
modeBanner:
'System mode is currently {mode}. V2 minute aggregation will not run; this config can be prepared now and takes effect after switching to {modeV2}. Change mode under System Settings → Feature switches.',
modeClosed: 'Channel monitor disabled',
modeV1: 'V1 active probes',
modeV2: 'V2 passive monitoring',
enableTitle: 'Enable V2 aggregation',
enableHint:
'Applies when system mode is V2. Turning this off only stops this config’s aggregation; the system mode switch remains under Feature switches.',
refreshTitle: 'Aggregation interval',
refreshHint: 'Affects matrix time granularity and refresh cadence',
refreshAria: 'Aggregation interval',
platformsTitle: 'Platforms and models',
platformsHint:
'Leave empty = show all real model names; when filled, only listed models get their own rows and the rest roll into “Other”',
modelsPlaceholder: 'Empty = all real models; or list popular models (rest → Other)',
badgeAllModels: 'All models',
badgeOther: '+ Other',
groupsTitle: 'Monitored groups',
groupsSelected: '{count} groups selected',
groupsAll: 'All groups',
groupsEmpty: 'No groups available',
errorsTitle: 'Error categories and ignores',
errorsHint:
'Checked “ignore” categories are excluded from error rate and health score, but still appear greyed in the error breakdown. Unmatched errors roll into “Other”.',
ignoredSummary: 'Ignored {ignored} categories · counted in error rate {counted} categories',
healthTitle: 'Health thresholds',
healthHint:
'Controls user-facing color bands and overall score. Defaults are tolerant so small error rates or low cache do not immediately show as unhealthy.',
fields: {
minimumSample: 'Minimum samples',
warningError: 'Error rate watch %',
criticalError: 'Error rate critical %',
targetTtft: 'TTFT target ms',
warningTtft: 'TTFT watch ms',
criticalTtft: 'TTFT critical ms',
warningCache: 'Cache rate watch %',
criticalCache: 'Cache rate critical %',
},
namedModelsEmpty: 'Platform model lists are empty: every real model name will be shown (not folded into “Other”).',
namedModelsCount: 'Showing {count} named model dimensions; unlisted models fold into per-platform “Other”.',
userContractTitle: 'User-facing display contract',
userContract: {
health: 'Health color weights: error rate 60% + first-token P50 20% + cache rate 20% (thresholds configurable above)',
trend: 'Trend can switch between pulse matrix and line chart (error · cache · first token)',
latency: 'Latency shows AVG · P50 · P90; absolute request / error counts are not shown',
models: 'Empty model lists show real names and never dump everything into “Other”',
},
},
admin: {
descriptionV1:
'System mode is V1 active probes: manage probe monitors and run checks now; V2 aggregation does not run.',
descriptionV2:
'System mode is V2 passive monitoring: configure aggregation dimensions; V1 active probes do not run.',
tabAria: 'Monitor management',
tabV2: 'V2 data monitor config',
tabV1Active: 'V1 active probes',
tabV1History: 'V1 history (probes not active in current mode)',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import channelMonitorV2 from './channelMonitorV2'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -9,6 +10,7 @@ export default {
...landing,
...common,
...dashboard,
...channelMonitorV2,
...batchImage,
admin,
...misc,
@@ -0,0 +1,116 @@
/** Channel Monitor V2 (user + admin passive monitor UI) */
export default {
channelMonitorV2: {
title: '渠道监控',
updating: '正在更新数据',
updatedTo: '更新至 {time}',
partialCoverage: '部分历史覆盖',
timeRange: '时间范围',
clearFilters: '重置',
refreshingFilters: '筛选条件已变化,正在刷新矩阵、趋势和明细…',
switchingData: '正在切换筛选数据…',
summaryAria: '筛选范围整体汇总',
loadFailed: '渠道监控加载失败',
detailLoadFailed: '渠道监控明细加载失败',
otherModels: '其他模型',
ignored: '忽略',
currentUser: '当前用户',
ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' },
filters: {
platform: '平台', allPlatforms: '全部', group: '分组', allGroups: '全部', model: '模型', allModels: '全部',
empty: '暂无可选项', selectedCount: '{count} 项', labelValue: '{label}:{value}'
},
groupBy: {
label: '展示维度', platform: '平台', platformGroup: '平台 / 分组', platformModel: '平台 / 模型', platformGroupModel: '平台 / 分组 / 模型'
},
trendView: { label: '趋势视图', pulse: '色块矩阵', line: '折线图' },
healthMode: { label: '健康显示', overall: '综合', success: '错误率', ttft: '首 Token', cache: '缓存率' },
tabs: { aria: '明细维度', models: '模型', errors: '错误原因', users: '用户排行' },
metrics: {
rpm: 'RPM', tpm: 'TPM', rpmDetail: '请求速率', tpmDetail: 'Token 速率', errorRate: '错误率', ttft: '首 Token', ttftP50: '首 Token P50', durationP50: '请求时长 P50', cacheRate: '缓存率', cacheDetail: '读缓存占比', successRate: '成功率',
successRateValue: '成功率 {value}', errorRateValue: '错误率 {value}', rpmValue: 'RPM {value}', tpmValue: 'TPM {value}', ttftValue: '首 Token {value}', durationValue: '请求时长 {value}', cacheRateValue: '缓存率 {value}'
},
table: { platformModel: '平台 / 模型', rank: '排名', user: '用户' },
empty: { title: '没有可展示的数据', description: '尝试调整时间范围或筛选条件' },
bucket: { minutes: '{count} 分钟粒度', hours: '{count} 小时粒度', days: '{count} 天粒度' },
matrix: {
title: '可用性趋势', description: '每行是一种渠道组合,每个色块代表一个统计区间;悬停查看明细', wheelZoom: '在色块上滚轮缩放', wheelZoomX: '在色块上滚轮横向缩放', dimension: '渠道维度', emptyTitle: '当前筛选窗口没有矩阵数据', legendAria: '健康分数图例', bad: '差', good: '好', healthyLegend: '健康 (≥80)', warningLegend: '需关注 (50–79)', criticalLegend: '异常 (<50)', unknownLegend: '无流量 / 样本不足', noTraffic: '该区间无流量', noTrafficAt: '{time} · 无流量', scoreLine: '健康分 {score}', resetZoom: '重置缩放'
},
chart: {
title: '可用性趋势', description: '平滑趋势:错误率 · 首 Token P50 · 缓存率', emptyTitle: '当前筛选窗口没有趋势数据', errorLegend: '错误率(左轴 %)', cacheLegend: '缓存率(左轴 %)', ttftLegend: '首 Token P50(右轴)', errorDataset: '错误率趋势 %', cacheDataset: '缓存率趋势 %', ttftDataset: '首 Token 趋势 P50 (ms)', percentAxis: '比率 %', resetZoom: '重置缩放'
},
errorDetail: { http: 'HTTP {code}', upstream: '上游 {code}', noMessage: '无错误消息', empty: '仅展示分类占比(样本消息仅管理员可见)' },
errorCategories: {
content_policy: '内容策略', authentication: '认证失败', context_limit: '上下文超限', invalid_request: '请求格式', model_unsupported: '模型不支持', group_access: '分组权限', quota_or_balance: '额度或余额', account_pool_unavailable: '账号池不可用', rate_or_capacity: '限流或容量', timeout: '超时', transport_or_stream: '传输或流', upstream_forbidden: '上游拒绝', not_found: '资源不存在', client_cancelled: '客户端取消', upstream_5xx: '上游 5xx', internal: '内部错误', other: '其他'
},
rank: {
gold: '第 1 名 金',
silver: '第 2 名 银',
bronze: '第 3 名 铜',
place: '第 {n} 名',
unranked: '未上榜',
},
settings: {
title: 'V2 数据监控配置',
description:
'配置被动用量汇总维度(平台 / 模型 / 分组)与刷新频率。健康色与明细在用户端 /monitor 以比例、RPM/TPM 展示,不暴露绝对请求量。',
save: '保存',
loading: '加载中...',
loadFailed: 'V2 配置加载失败',
saveSuccess: 'V2 监控配置已保存',
saveFailed: 'V2 配置保存失败',
modeBanner:
'当前系统设置为 {mode}。V2 分钟聚合不会运行;此处配置可预先保存,切换到 {modeV2} 后立即生效。可在系统设置 → 功能开关调整。',
modeClosed: '渠道监控已关闭',
modeV1: 'V1 主动探测',
modeV2: 'V2 被动监控',
enableTitle: '启用 V2 汇总',
enableHint: '在系统模式为 V2 时生效;关闭后仅停止本配置的汇总,系统模式开关仍在「功能开关」',
refreshTitle: '汇总频率',
refreshHint: '影响矩阵时间粒度与刷新节奏',
refreshAria: '汇总频率',
platformsTitle: '平台与模型',
platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」',
modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)',
badgeAllModels: '全部模型',
badgeOther: '+ 其他',
groupsTitle: '监控分组',
groupsSelected: '已选择 {count} 个分组',
groupsAll: '全部分组',
groupsEmpty: '没有可选择的分组',
errorsTitle: '错误分类与忽略',
errorsHint:
'勾选「忽略」的类别不计入错误率与健康分,仍在错误原因列表中以灰色显示并标记忽略。未匹配的错误归入「其他」。',
ignoredSummary: '已忽略 {ignored} 类 · 计入错误率 {counted} 类',
healthTitle: '健康阈值',
healthHint: '控制用户端色块和整体评分。默认阈值较宽松,避免少量错误或低缓存率立即显示异常。',
fields: {
minimumSample: '最小样本数',
warningError: '错误率关注 %',
criticalError: '错误率异常 %',
targetTtft: 'TTFT 目标 ms',
warningTtft: 'TTFT 关注 ms',
criticalTtft: 'TTFT 异常 ms',
warningCache: '缓存率关注 %',
criticalCache: '缓存率异常 %',
},
namedModelsEmpty: '各平台模型列表为空:将展示全部真实模型名(不归入「其他」)。',
namedModelsCount: '将展示 {count} 个命名模型维度;名单外模型归入各平台「其他」。',
userContractTitle: '用户端展示约定',
userContract: {
health: '健康色三指标:错误率 60% + 首 Token P50 20% + 缓存率 20%(阈值可在上方配置)',
trend: '趋势可切换色块矩阵 / 折线图(错误率 · 缓存率 · 首 Token)',
latency: '延迟展示 AVG · P50 · P90;不展示绝对请求数 / 错误数',
models: '模型列表留空时展示真实模型名,不会全部归入「其他」',
},
},
admin: {
descriptionV1: '当前系统设置为 V1 主动探测:可管理监控项并立即检测;V2 聚合不会运行。',
descriptionV2: '当前系统设置为 V2 被动监控:配置聚合维度;V1 主动探测不会运行。',
tabAria: '监控管理',
tabV2: 'V2 数据监控配置',
tabV1Active: 'V1 主动探测',
tabV1History: 'V1 历史(当前模式未启用探测)',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import channelMonitorV2 from './channelMonitorV2'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -9,6 +10,7 @@ export default {
...landing,
...common,
...dashboard,
...channelMonitorV2,
...batchImage,
admin,
...misc,
@@ -0,0 +1,172 @@
<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
:auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
<MonitorCardGrid
:items="items"
:window="currentWindow"
:countdown-seconds="countdown"
:loading="loading"
:detail-cache="detailCache"
@card-click="openDetail"
/>
<MonitorDetailDialog
:show="showDetail"
:monitor-id="detailTarget?.id ?? null"
:title="detailTitle"
@close="closeDetail"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
list as listChannelMonitorViews,
status as fetchChannelMonitorDetail,
type UserMonitorView,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import AppLayout from '@/components/layout/AppLayout.vue'
import MonitorHero, {
type MonitorWindow,
type OverallStatus,
} from '@/components/user/monitor/MonitorHero.vue'
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let abortController: AbortController | null = null
const autoRefresh = useAutoRefresh({
storageKey: 'channel-status-auto-refresh',
intervals: [30, 60, 120] as const,
defaultInterval: DEFAULT_INTERVAL_SECONDS,
onRefresh: () => reload(true),
shouldPause: () => document.hidden || loading.value,
})
const countdown = autoRefresh.countdown
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
}
return 'operational'
})
const detailTitle = computed(() => {
return detailTarget.value?.name || t('channelStatus.detailTitle')
})
// ── Loaders ──
async function reload(silent = false) {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
if (!silent) loading.value = true
try {
const res = await listChannelMonitorViews({ signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
items.value = res.items || []
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
} finally {
if (abortController === ctrl) {
if (!silent) loading.value = false
countdown.value = DEFAULT_INTERVAL_SECONDS
abortController = null
}
}
}
async function manualReload() {
await reload(false)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if (currentWindow.value !== '7d') {
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
}
}
async function loadDetail(id: number, force = false) {
if (!force && detailCache[id]) return
try {
detailCache[id] = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
}
}
async function ensureDetailsForWindow() {
if (currentWindow.value === '7d') return
await Promise.all(items.value.map(it => loadDetail(it.id)))
}
// ── Handlers ──
async function handleWindowChange(value: MonitorWindow) {
currentWindow.value = value
await ensureDetailsForWindow()
}
function openDetail(row: UserMonitorView) {
detailTarget.value = row
showDetail.value = true
}
function closeDetail() {
showDetail.value = false
detailTarget.value = null
}
watch(items, () => {
void ensureDetailsForWindow()
})
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
if (enabled === false) autoRefresh.stop()
else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
onMounted(() => {
void reload(false)
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
autoRefresh.setEnabled(true)
}
})
onBeforeUnmount(() => {
if (abortController) abortController.abort()
})
</script>
@@ -0,0 +1,886 @@
<template>
<AppLayout>
<div class="space-y-6 pb-12">
<!-- Ops-style elevated shell: title toolbar + filters (mirrors OpsDashboardHeader) -->
<section
class="card sticky top-0 z-20 !rounded-3xl !border-0 p-0 shadow-sm ring-1 ring-gray-900/5 backdrop-blur-sm dark:!bg-dark-800 dark:ring-dark-700 supports-[backdrop-filter]:bg-white/95 dark:supports-[backdrop-filter]:bg-dark-800/95"
>
<header class="page-header mb-0 flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 px-5 py-4 dark:border-dark-700 sm:px-6">
<div class="min-w-0">
<h1 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.title') }}
</h1>
<div class="page-description mt-1.5 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="relative flex h-2 w-2 shrink-0">
<span
class="relative inline-flex h-2 w-2 rounded-full"
:class="loading || refreshing ? 'bg-gray-400' : 'bg-green-500'"
></span>
</span>
<span v-if="refreshing" class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-300">
<LoadingSpinner size="sm" />
{{ t('channelMonitorV2.updating') }}
</span>
<span v-else-if="snapshot?.coverage.data_through">
{{ t('channelMonitorV2.updatedTo', { time: formatTime(snapshot.coverage.data_through) }) }}
</span>
<span v-else class="text-gray-400">{{ t('common.loading') }}</span>
<span
v-if="snapshot && !snapshot.coverage.coverage_complete"
class="badge badge-warning"
>
{{ t('channelMonitorV2.partialCoverage') }}
</span>
</div>
</div>
<button
class="btn btn-secondary btn-icon flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
type="button"
:title="t('common.refresh')"
:disabled="loading"
@click="reload(false)"
>
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
</button>
</header>
<!-- Single compact toolbar row: range · filters · view controls -->
<div class="monitor-toolbar flex flex-nowrap items-center gap-1.5 overflow-x-auto px-4 py-3 sm:gap-2 sm:px-5">
<div
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.timeRange')"
>
<button
v-for="option in ranges"
:key="option.value"
type="button"
class="tab !px-2 !py-1 text-xs sm:!px-2.5"
:class="filter.range === option.value ? 'tab-active' : ''"
@click="setRange(option.value)"
>
{{ option.label }}
</button>
</div>
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 sm:block" aria-hidden="true"></span>
<FilterMultiSelect
v-model="filter.platforms"
compact
:label="t('channelMonitorV2.filters.platform')"
:all-label="t('channelMonitorV2.filters.allPlatforms')"
:options="platformOptions"
/>
<FilterMultiSelect
v-model="selectedGroupIds"
compact
:label="t('channelMonitorV2.filters.group')"
:all-label="t('channelMonitorV2.filters.allGroups')"
:options="groupOptions"
/>
<FilterMultiSelect
v-model="filter.models"
compact
:label="t('channelMonitorV2.filters.model')"
:all-label="t('channelMonitorV2.filters.allModels')"
:options="modelOptions"
/>
<button
type="button"
class="btn btn-ghost btn-sm shrink-0 !px-2 !py-1 text-xs"
:disabled="!hasDimensionFilter"
:class="!hasDimensionFilter ? 'opacity-40' : ''"
@click="clearDimensions"
>
{{ t('channelMonitorV2.clearFilters') }}
</button>
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 md:block" aria-hidden="true"></span>
<Select
v-model="matrixGroupBy"
:options="matrixGroupOptions"
:placeholder="t('channelMonitorV2.groupBy.label')"
class="monitor-toolbar-select w-[7.5rem] shrink-0 sm:w-[8.5rem]"
/>
<div
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.trendView.label')"
>
<button
type="button"
class="tab !px-2 !py-1 text-xs"
:class="trendView === 'pulse' ? 'tab-active' : ''"
@click="trendView = 'pulse'"
>
{{ t('channelMonitorV2.trendView.pulse') }}
</button>
<button
type="button"
class="tab !px-2 !py-1 text-xs"
:class="trendView === 'line' ? 'tab-active' : ''"
@click="trendView = 'line'"
>
{{ t('channelMonitorV2.trendView.line') }}
</button>
</div>
<div
v-if="trendView === 'pulse'"
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.healthMode.label')"
>
<button
v-for="option in healthModeOptions"
:key="option.value"
type="button"
class="tab !px-2 !py-1 text-xs"
:class="healthMode === option.value ? 'tab-active' : ''"
@click="healthMode = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</section>
<!-- Overview-first KPI strip (Ops metric nest order) -->
<section
v-if="snapshot"
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
:class="showThroughput ? 'xl:grid-cols-6' : 'xl:grid-cols-4'"
:aria-label="t('channelMonitorV2.summaryAria')"
>
<MetricCell
v-if="showThroughput"
:label="t('channelMonitorV2.metrics.rpm')"
:value="formatRate(snapshot.metrics.rpm)"
:detail="t('channelMonitorV2.metrics.rpmDetail')"
:title="exactRate(snapshot.metrics.rpm)"
/>
<MetricCell
v-if="showThroughput"
:label="t('channelMonitorV2.metrics.tpm')"
:value="formatRate(snapshot.metrics.tpm)"
:detail="t('channelMonitorV2.metrics.tpmDetail')"
:title="exactRate(snapshot.metrics.tpm)"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.errorRate')"
:value="formatPercent(snapshot.metrics.error_rate)"
:detail="t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - snapshot.metrics.error_rate) })"
:state="snapshot.health.error_rate"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.ttftP50')"
:value="formatMs(snapshot.metrics.ttft.p50_ms)"
:detail="latencyKpiSecondary(snapshot.metrics.ttft)"
:title="latencyDetail(snapshot.metrics.ttft)"
:state="snapshot.health.ttft"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.durationP50')"
:value="formatMs(snapshot.metrics.duration.p50_ms)"
:detail="latencyKpiSecondary(snapshot.metrics.duration)"
:title="latencyDetail(snapshot.metrics.duration)"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.cacheRate')"
:value="formatPercent(snapshot.metrics.cache_rate)"
:detail="t('channelMonitorV2.metrics.cacheDetail')"
:state="snapshot.health.cache || snapshot.health.overall"
/>
</section>
<section
v-else-if="loading"
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
:class="showThroughput ? 'xl:grid-cols-6' : 'xl:grid-cols-4'"
aria-hidden="true"
>
<div
v-for="i in (showThroughput ? 6 : 4)"
:key="i"
class="h-24 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"
/>
</section>
<div class="relative min-h-[320px]">
<MonitorTrendChart
v-if="trendView === 'line'"
:trend="snapshot?.trend || []"
:coverage="snapshot?.coverage || null"
:loading="loading && !snapshot"
/>
<RelayPulseMatrix
v-else-if="matrix"
:rows="matrixRows"
:coverage="matrix.coverage"
:health-mode="healthMode"
:show-throughput="showThroughput"
/>
<div
v-else-if="loading"
class="card flex min-h-[320px] items-center justify-center !rounded-3xl !border-0 text-sm text-gray-400 shadow-sm ring-1 ring-gray-900/5 dark:ring-dark-700"
>
<span class="animate-pulse">{{ t('common.loading') }}</span>
</div>
</div>
<section class="card flex min-h-0 flex-col overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="border-b border-gray-100 px-5 pt-4 dark:border-dark-700 sm:px-6">
<nav class="tabs w-full max-w-md sm:w-auto" role="tablist" :aria-label="t('channelMonitorV2.tabs.aria')">
<button
v-for="item in tabs"
:key="item.value"
type="button"
role="tab"
class="tab flex-1 sm:flex-none"
:aria-selected="activeTab === item.value"
:class="activeTab === item.value ? 'tab-active' : ''"
@click="activeTab = item.value"
>
{{ item.label }}
</button>
</nav>
</div>
<div class="min-h-0 max-h-[min(52vh,520px)] overflow-auto p-4 sm:p-5">
<div v-if="activeTab === 'models'" class="table-container border-0">
<table class="table monitor-table min-w-[720px]">
<thead>
<tr>
<th>{{ t('channelMonitorV2.table.platformModel') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tpm') }}</th>
<th>{{ t('channelMonitorV2.metrics.errorRate') }}</th>
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
<th>{{ t('channelMonitorV2.metrics.durationP50') }}</th>
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in modelRows"
:key="`${row.platform}:${row.model}`"
class="cursor-pointer"
@click="drillModel(row)"
>
<td>
<div class="flex items-center gap-2">
<span :class="statusDot(row.health)" aria-hidden="true"></span>
<div>
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ row.platform }}</span>
<strong class="font-semibold text-gray-900 dark:text-white">
{{ row.model === '__other__' ? t('channelMonitorV2.otherModels') : row.model }}
</strong>
</div>
</div>
</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.tpm) }}</td>
<td>
<span class="block">{{ formatPercent(row.metrics.error_rate) }}</span>
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - row.metrics.error_rate) }) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.duration.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.duration) }}</small>
</td>
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="activeTab === 'errors'" class="space-y-3">
<div
v-for="row in errorRows"
:key="row.category"
class="rounded-2xl bg-gray-50 p-4 text-sm dark:bg-dark-900/30"
:class="row.ignored ? 'opacity-60' : ''"
>
<button
type="button"
class="grid w-full grid-cols-[minmax(100px,200px)_1fr_auto_auto] items-center gap-3 text-left"
@click="toggleError(row.category)"
>
<span class="flex min-w-0 items-center gap-1.5 truncate text-gray-700 dark:text-gray-200">
<span class="truncate">{{ errorLabel(row.category) }}</span>
<span v-if="row.ignored" class="badge badge-gray shrink-0 !px-1.5 !py-0 text-[10px]">{{ t('channelMonitorV2.ignored') }}</span>
</span>
<span class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
<i
class="block h-full rounded-full"
:class="row.ignored ? 'bg-gray-400 dark:bg-gray-500' : 'bg-gradient-to-r from-red-400 to-red-500'"
:style="{ width: `${Math.max(2, row.rate * 100)}%` }"
></i>
</span>
<small
class="w-14 text-right text-xs tabular-nums"
:class="row.ignored ? 'text-gray-400' : 'text-gray-500'"
>{{ formatPercent(row.rate) }}</small>
<Icon name="chevronDown" size="sm" :class="['text-gray-400 transition-transform', expandedErrors.has(row.category) ? 'rotate-180' : '']" />
</button>
<div v-if="expandedErrors.has(row.category)" class="mt-3 space-y-2 border-t border-gray-100 pt-3 dark:border-dark-700">
<template v-if="isAdmin && (row.details || []).length">
<div
v-for="(detail, index) in row.details || []"
:key="`${row.category}:${index}:${detail.message}`"
class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-900/50 dark:text-dark-300"
>
<div class="mb-1 flex flex-wrap items-center gap-2">
<span class="badge badge-gray !px-1.5 !py-0 text-[10px]">{{ detail.platform || '-' }}</span>
<span class="truncate font-medium">{{ detail.model || '-' }}</span>
<span v-if="detail.status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.http', { code: detail.status_code }) }}</span>
<span v-if="detail.upstream_status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.upstream', { code: detail.upstream_status_code }) }}</span>
<span class="ml-auto text-gray-400">×{{ detail.count }}</span>
</div>
<p class="break-words leading-relaxed">{{ detail.message || detail.error_type || t('channelMonitorV2.errorDetail.noMessage') }}</p>
</div>
</template>
<p v-else class="text-xs text-gray-400">{{ t('channelMonitorV2.errorDetail.empty') }}</p>
</div>
</div>
</div>
<div v-else class="table-container border-0">
<table class="table monitor-table min-w-[640px]">
<thead>
<tr>
<th class="w-16">{{ t('channelMonitorV2.table.rank') }}</th>
<th>{{ t('channelMonitorV2.table.user') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tpm') }}</th>
<th>{{ t('channelMonitorV2.metrics.errorRate') }}</th>
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
<th>{{ t('channelMonitorV2.metrics.durationP50') }}</th>
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in userRows"
:key="row.user_id || row.display_label"
:class="row.is_self
? 'bg-primary-50 ring-1 ring-inset ring-primary-200/80 dark:bg-primary-900/25 dark:ring-primary-700/50'
: ''"
>
<td><MonitorRankBadge :rank="row.rank" /></td>
<td>
<strong
class="font-semibold"
:class="row.is_self ? 'text-primary-700 dark:text-primary-300' : 'text-gray-900 dark:text-white'"
>
{{ row.display_label }}
<span
v-if="row.is_self"
class="badge badge-primary ml-2 !px-1.5 !py-0 text-[10px]"
>{{ t('channelMonitorV2.currentUser') }}</span>
</strong>
</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.tpm) }}</td>
<td>
<span class="block">{{ formatPercent(row.metrics.error_rate) }}</span>
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - row.metrics.error_rate) }) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.duration.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.duration) }}</small>
</td>
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="tabLoading" class="empty-state py-10 text-sm text-gray-400">{{ t('common.loading') }}</div>
<div v-else-if="activeRowsEmpty" class="empty-state py-10">
<p class="empty-state-title text-base">{{ t('channelMonitorV2.empty.title') }}</p>
<p class="empty-state-description">{{ t('channelMonitorV2.empty.description') }}</p>
</div>
</div>
</section>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Select from '@/components/common/Select.vue'
import FilterMultiSelect from '@/features/channel-monitor-v2/FilterMultiSelect.vue'
import MetricCell from '@/features/channel-monitor-v2/MetricCell.vue'
import MonitorRankBadge from '@/features/channel-monitor-v2/MonitorRankBadge.vue'
import MonitorTrendChart from '@/features/channel-monitor-v2/MonitorTrendChart.vue'
import RelayPulseMatrix from '@/features/channel-monitor-v2/RelayPulseMatrix.vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isChannelMonitorThroughputHidden } from '@/utils/featureFlags'
import * as api from '@/api/channelMonitorV2'
import type {
HealthState,
MonitorDimensions,
MonitorErrorRow,
MonitorFilter,
MonitorHealth,
MonitorMatrixGroupBy,
MonitorMatrixResponse,
MonitorModelRow,
MonitorRange,
MonitorSnapshot,
MonitorUserRow,
} from '@/api/channelMonitorV2'
import {
formatLatencyKpiSecondary,
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorPercent,
formatMonitorThroughput,
healthScoreClass,
monitorErrorCategoryLabel,
} from '@/features/channel-monitor-v2/monitorFormat'
type Tab = 'models' | 'errors' | 'users'
type HealthMode = 'overall' | 'success' | 'ttft' | 'cache'
type TrendView = 'pulse' | 'line'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
const { t, te, locale } = useI18n()
const isAdmin = computed(() => authStore.isAdmin)
/** Admins always see RPM/TPM; users honor the hide-throughput system setting. */
const showThroughput = computed(() => isAdmin.value || !isChannelMonitorThroughputHidden())
const ranges = computed(() => [
{ value: '90m' as MonitorRange, label: t('channelMonitorV2.ranges.90m') },
{ value: '24h' as MonitorRange, label: t('channelMonitorV2.ranges.24h') },
{ value: '7d' as MonitorRange, label: t('channelMonitorV2.ranges.7d') },
{ value: '30d' as MonitorRange, label: t('channelMonitorV2.ranges.30d') },
])
const tabs = computed(() => [
{ value: 'models' as Tab, label: t('channelMonitorV2.tabs.models') },
{ value: 'errors' as Tab, label: t('channelMonitorV2.tabs.errors') },
{ value: 'users' as Tab, label: t('channelMonitorV2.tabs.users') },
])
const matrixGroupOptions = computed(() => [
{ value: 'platform' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platform') },
{ value: 'platform_group' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroup') },
{ value: 'platform_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformModel') },
{ value: 'platform_group_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroupModel') },
])
const healthModeOptions = computed(() => [
{ value: 'overall' as HealthMode, label: t('channelMonitorV2.healthMode.overall') },
{ value: 'success' as HealthMode, label: t('channelMonitorV2.healthMode.success') },
{ value: 'ttft' as HealthMode, label: t('channelMonitorV2.healthMode.ttft') },
{ value: 'cache' as HealthMode, label: t('channelMonitorV2.healthMode.cache') },
])
const filter = ref<MonitorFilter>({
range: parseRange(route.query.range),
platforms: csv(route.query.platform),
groupIds: csv(route.query.group).map(Number).filter(Boolean),
models: csv(route.query.model),
})
const activeTab = ref<Tab>(
(['models', 'errors', 'users'].includes(String(route.query.tab)) ? route.query.tab : 'models') as Tab
)
const matrixGroupBy = ref<MonitorMatrixGroupBy>(parseMatrixGroupBy(route.query.group_by))
const healthMode = ref<HealthMode>(parseHealthMode(route.query.health_mode))
const trendView = ref<TrendView>(parseTrendView(route.query.trend_view))
const dimensions = ref<MonitorDimensions>({ platforms: [], groups: [], models: [] })
const snapshot = ref<MonitorSnapshot | null>(null)
const matrix = ref<MonitorMatrixResponse | null>(null)
const modelRows = ref<MonitorModelRow[]>([])
const errorRows = ref<MonitorErrorRow[]>([])
const userRows = ref<MonitorUserRow[]>([])
const loading = ref(false)
const tabLoading = ref(false)
const refreshing = ref(false)
const expandedErrors = ref(new Set<string>())
let controller: AbortController | null = null
let sequence = 0
let autoRefreshTimer: number | null = null
const hasDimensionFilter = computed(
() => filter.value.platforms.length + filter.value.groupIds.length + filter.value.models.length > 0
)
// Full platform catalog (never pruned). Groups/models cascade by selected platforms
// so choosing a platform narrows the other pickers without collapsing platforms.
const platformOptions = computed(() =>
(dimensions.value.platforms || []).map((item) => ({
value: item.value,
label: item.label,
}))
)
const selectedPlatforms = computed(() => new Set(filter.value.platforms))
const groupOptions = computed(() =>
(dimensions.value.groups || [])
.filter(
(item) =>
selectedPlatforms.value.size === 0 ||
!item.platform ||
selectedPlatforms.value.has(item.platform),
)
.map((item) => ({
value: String(item.id),
label: item.platform ? `${item.platform} / ${item.name || `#${item.id}`}` : item.name || `#${item.id}`,
}))
)
const modelOptions = computed(() =>
(dimensions.value.models || [])
.filter(
(item) =>
selectedPlatforms.value.size === 0 ||
!item.platform ||
selectedPlatforms.value.has(item.platform),
)
.map((item) => ({
value: item.value,
label:
item.platform && !item.label.includes(item.platform)
? `${item.platform} / ${item.label}`
: item.label,
}))
)
const selectedGroupIds = computed({
get: () => filter.value.groupIds.map(String),
set: (value: string[]) => {
filter.value.groupIds = value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
},
})
// Soft-prune group/model selections that fall outside the platform cascade.
// Do NOT wipe when options are temporarily empty (loading); only drop invalid ids.
watch(
[groupOptions, modelOptions],
() => {
if (groupOptions.value.length > 0) {
const allowed = new Set(groupOptions.value.map((item) => item.value))
const next = filter.value.groupIds.filter((id) => allowed.has(String(id)))
if (next.length !== filter.value.groupIds.length) {
filter.value.groupIds = next
}
}
if (modelOptions.value.length > 0) {
const allowed = new Set(modelOptions.value.map((item) => item.value))
const next = filter.value.models.filter((model) => allowed.has(model))
if (next.length !== filter.value.models.length) {
filter.value.models = next
}
}
},
{ flush: 'post' },
)
const activeRowsEmpty = computed(() =>
activeTab.value === 'models'
? modelRows.value.length === 0
: activeTab.value === 'errors'
? errorRows.value.length === 0
: userRows.value.length === 0
)
const matrixRows = computed(() => {
const items = matrix.value?.items || []
// platform_group views should only show real groups, never bare platform placeholders.
if (matrixGroupBy.value === 'platform_group' || matrixGroupBy.value === 'platform_group_model') {
return items.filter((row) => row.group_id != null && Number(row.group_id) > 0)
}
return items
})
function csv(value: unknown) {
return typeof value === 'string' ? value.split(',').filter(Boolean) : []
}
function parseRange(value: unknown): MonitorRange {
return ['90m', '24h', '7d', '30d'].includes(String(value)) ? (value as MonitorRange) : '90m'
}
function parseMatrixGroupBy(value: unknown): MonitorMatrixGroupBy {
const allowed: MonitorMatrixGroupBy[] = [
'platform',
'platform_group',
'platform_model',
'platform_group_model',
]
return allowed.includes(value as MonitorMatrixGroupBy)
? (value as MonitorMatrixGroupBy)
: 'platform_group'
}
function parseHealthMode(value: unknown): HealthMode {
const allowed: HealthMode[] = ['overall', 'success', 'ttft', 'cache']
return allowed.includes(value as HealthMode) ? (value as HealthMode) : 'overall'
}
function parseTrendView(value: unknown): TrendView {
return value === 'line' ? 'line' : 'pulse'
}
function syncQuery() {
void router.replace({
query: {
range: filter.value.range,
platform: filter.value.platforms.join(',') || undefined,
group: filter.value.groupIds.join(',') || undefined,
model: filter.value.models.join(',') || undefined,
group_by: matrixGroupBy.value,
health_mode: healthMode.value,
trend_view: trendView.value === 'line' ? 'line' : undefined,
tab: activeTab.value,
},
})
}
/** Dimensions catalog: range only — never re-filtered by platform/group/model selection. */
async function loadDimensions(signal?: AbortSignal, id = sequence) {
const rangeOnly: MonitorFilter = {
range: filter.value.range,
platforms: [],
groupIds: [],
models: [],
}
const next = await api.getDimensions(rangeOnly, isAdmin.value, signal)
if (id !== sequence) return
dimensions.value = next
}
async function loadMetrics(signal?: AbortSignal, id = sequence) {
const [nextSnapshot, nextMatrix] = await Promise.all([
api.getSnapshot(filter.value, isAdmin.value, signal),
api.getMatrix(filter.value, matrixGroupBy.value, isAdmin.value, signal),
])
if (id !== sequence) return
snapshot.value = nextSnapshot
matrix.value = nextMatrix
scheduleAutoRefresh()
await loadTab(signal, id)
}
async function reload(silent = true) {
controller?.abort()
const request = new AbortController()
controller = request
const id = ++sequence
refreshing.value = true
if (!silent) loading.value = true
try {
// Catalog + metrics in parallel; catalog ignores dimension filters so options never shrink.
await Promise.all([
loadDimensions(request.signal, id),
loadMetrics(request.signal, id),
])
} catch (error) {
if ((error as { name?: string }).name !== 'CanceledError') {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
}
} finally {
if (id === sequence) {
loading.value = false
tabLoading.value = false
refreshing.value = false
}
}
}
/** When only range changes, still refresh dimensions; dimension filters only re-load metrics. */
async function reloadMetricsOnly(silent = true) {
controller?.abort()
const request = new AbortController()
controller = request
const id = ++sequence
refreshing.value = true
if (!silent) loading.value = true
try {
await loadMetrics(request.signal, id)
} catch (error) {
if ((error as { name?: string }).name !== 'CanceledError') {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
}
} finally {
if (id === sequence) {
loading.value = false
tabLoading.value = false
refreshing.value = false
}
}
}
async function loadTab(signal?: AbortSignal, id = sequence) {
tabLoading.value = true
try {
if (activeTab.value === 'models') {
modelRows.value = (await api.getModels(filter.value, isAdmin.value, signal)).items || []
} else if (activeTab.value === 'errors') {
errorRows.value = (await api.getErrors(filter.value, isAdmin.value, signal)).items || []
} else {
userRows.value = (await api.getUsers(filter.value, isAdmin.value, signal)).items || []
}
} catch (error) {
const e = error as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.detailLoadFailed')))
} finally {
if (id === sequence) tabLoading.value = false
}
}
function setRange(value: MonitorRange) {
filter.value.range = value
}
function clearDimensions() {
// Replace arrays so deep watch always fires and metrics reload full window.
filter.value = {
...filter.value,
platforms: [],
groupIds: [],
models: [],
}
}
function scheduleAutoRefresh() {
if (autoRefreshTimer) {
window.clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
const seconds = snapshot.value?.config?.refresh_interval_seconds || 300
autoRefreshTimer = window.setInterval(() => {
if (!loading.value && !refreshing.value) {
void reload(true)
}
}, Math.max(60, seconds) * 1000)
}
function drillModel(row: MonitorModelRow) {
filter.value.platforms = [row.platform]
filter.value.models = [row.model]
}
function formatRate(value: number) {
return formatMonitorThroughput(value)
}
function exactRate(value: number) {
return Intl.NumberFormat(locale.value || undefined, { maximumFractionDigits: 2 }).format(value || 0)
}
function formatPercent(value: number) {
return formatMonitorPercent(value)
}
function formatMs(value: number | null) {
return formatMonitorMs(value)
}
function latencyDetail(metric: {
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms?: number | null
}) {
return formatLatencyPrivacy(metric.p50_ms, metric.p90_ms, metric.avg_ms, metric.p95_ms)
}
/** KPI secondary: AVG · P90 under the P50 primary value. */
function latencyKpiSecondary(metric: {
p90_ms?: number | null
p95_ms: number | null
avg_ms?: number | null
}) {
return formatLatencyKpiSecondary(metric.avg_ms, metric.p90_ms, metric.p95_ms)
}
function formatTime(value: string) {
return new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function statusDot(health?: MonitorHealth | HealthState) {
if (!health || typeof health === 'string') {
return `status-dot health-${health || 'unknown'}`
}
// Prefer multi-band score when available; otherwise fall back to the coarse
// overall state for mixed-version/older payloads.
const klass =
health.score != null
? healthScoreClass(health, 'overall', 0)
: `health-${health.overall || 'unknown'}`
return `status-dot ${klass}`
}
function errorLabel(value: string) {
const key = `channelMonitorV2.errorCategories.${value}`
return te(key) ? t(key) : monitorErrorCategoryLabel(value)
}
function toggleError(category: string) {
const next = new Set(expandedErrors.value)
if (next.has(category)) next.delete(category)
else next.add(category)
expandedErrors.value = next
}
let lastRange: MonitorRange = filter.value.range
watch(
filter,
() => {
syncQuery()
const rangeChanged = filter.value.range !== lastRange
lastRange = filter.value.range
if (rangeChanged) void reload(true)
else void reloadMetricsOnly(true)
},
{ deep: true }
)
watch(matrixGroupBy, () => {
syncQuery()
void reloadMetricsOnly(true)
})
watch(healthMode, syncQuery)
watch(trendView, syncQuery)
watch(activeTab, () => {
syncQuery()
void loadTab()
})
onMounted(() => void reload(false))
onBeforeUnmount(() => {
controller?.abort()
if (autoRefreshTimer) window.clearInterval(autoRefreshTimer)
})
</script>
<style scoped>
.status-dot {
display: inline-block;
height: 0.5rem;
width: 0.5rem;
flex: none;
border-radius: 9999px;
}
/* Multi-stop green → yellow → red score bands */
.health-score10 { background: #16a34a; }
.health-score9 { background: #22c55e; }
.health-score8 { background: #4ade80; }
.health-score7 { background: #a3e635; }
.health-score6 { background: #facc15; }
.health-score5 { background: #fbbf24; }
.health-score4 { background: #f59e0b; }
.health-score3 { background: #f97316; }
.health-score2 { background: #fb7185; }
.health-score1 { background: #f87171; }
.health-score0 { background: rgb(239, 67, 67); }
.health-healthy { background: #22c55e; }
.health-warning { background: #f59e0b; }
.health-critical { background: #ef4444; }
.health-unknown { background: #9ca3af; }
.matrix-select {
min-width: 10rem;
}
details > summary::-webkit-details-marker {
display: none;
}
</style>
+7 -166
View File
@@ -1,172 +1,13 @@
<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
:auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
<MonitorCardGrid
:items="items"
:window="currentWindow"
:countdown-seconds="countdown"
:loading="loading"
:detail-cache="detailCache"
@card-click="openDetail"
/>
<MonitorDetailDialog
:show="showDetail"
:monitor-id="detailTarget?.id ?? null"
:title="detailTitle"
@close="closeDetail"
/>
</AppLayout>
<ChannelStatusV1View v-if="isV1" />
<ChannelStatusV2View v-else />
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
list as listChannelMonitorViews,
status as fetchChannelMonitorDetail,
type UserMonitorView,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import AppLayout from '@/components/layout/AppLayout.vue'
import MonitorHero, {
type MonitorWindow,
type OverallStatus,
} from '@/components/user/monitor/MonitorHero.vue'
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { computed } from 'vue'
import { isChannelMonitorV1Mode } from '@/utils/featureFlags'
import ChannelStatusV1View from './ChannelStatusV1View.vue'
import ChannelStatusV2View from './ChannelStatusV2View.vue'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let abortController: AbortController | null = null
const autoRefresh = useAutoRefresh({
storageKey: 'channel-status-auto-refresh',
intervals: [30, 60, 120] as const,
defaultInterval: DEFAULT_INTERVAL_SECONDS,
onRefresh: () => reload(true),
shouldPause: () => document.hidden || loading.value,
})
const countdown = autoRefresh.countdown
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
}
return 'operational'
})
const detailTitle = computed(() => {
return detailTarget.value?.name || t('channelStatus.detailTitle')
})
// ── Loaders ──
async function reload(silent = false) {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
if (!silent) loading.value = true
try {
const res = await listChannelMonitorViews({ signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
items.value = res.items || []
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
} finally {
if (abortController === ctrl) {
if (!silent) loading.value = false
countdown.value = DEFAULT_INTERVAL_SECONDS
abortController = null
}
}
}
async function manualReload() {
await reload(false)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if (currentWindow.value !== '7d') {
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
}
}
async function loadDetail(id: number, force = false) {
if (!force && detailCache[id]) return
try {
detailCache[id] = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
}
}
async function ensureDetailsForWindow() {
if (currentWindow.value === '7d') return
await Promise.all(items.value.map(it => loadDetail(it.id)))
}
// ── Handlers ──
async function handleWindowChange(value: MonitorWindow) {
currentWindow.value = value
await ensureDetailsForWindow()
}
function openDetail(row: UserMonitorView) {
detailTarget.value = row
showDetail.value = true
}
function closeDetail() {
showDetail.value = false
detailTarget.value = null
}
watch(items, () => {
void ensureDetailsForWindow()
})
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
if (enabled === false) autoRefresh.stop()
else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
onMounted(() => {
void reload(false)
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
autoRefresh.setEnabled(true)
}
})
onBeforeUnmount(() => {
if (abortController) abortController.abort()
})
const isV1 = computed(() => isChannelMonitorV1Mode())
</script>
@@ -0,0 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
const isV1 = vi.fn(() => false)
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => isV1(),
}))
vi.mock('../ChannelStatusV1View.vue', () => ({
default: defineComponent({ name: 'ChannelStatusV1View', setup: () => () => h('div', { 'data-testid': 'v1' }) }),
}))
vi.mock('../ChannelStatusV2View.vue', () => ({
default: defineComponent({ name: 'ChannelStatusV2View', setup: () => () => h('div', { 'data-testid': 'v2' }) }),
}))
import ChannelStatusView from '../ChannelStatusView.vue'
describe('ChannelStatusView mode switch', () => {
beforeEach(() => {
isV1.mockReset()
})
it('renders V2 when not in v1 mode', () => {
isV1.mockReturnValue(false)
const wrapper = mount(ChannelStatusView)
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(false)
})
it('renders V1 when in v1 mode', () => {
isV1.mockReturnValue(true)
const wrapper = mount(ChannelStatusView)
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(false)
})
})