mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
feat(channel-monitor-v2): 实现用户端 Ops 风格被动监控界面
提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层, 并统一 useI18n 避免语言包未挂载时键路径裸露。
This commit is contained in:
@@ -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',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 历史(当前模式未启用探测)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user