"frontend/src/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "52c745bc62d921d2014caf7aa8428829eabe3d83"
Commit 0e448297 authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'main' into dev

parents 9618cb56 93db889a
package service
import "time"
// OpsRealtimeTrafficSummary is a lightweight summary used by the Ops dashboard "Realtime Traffic" card.
// It reports QPS/TPS current/peak/avg for the requested time window.
type OpsRealtimeTrafficSummary struct {
// Window is a normalized label (e.g. "1min", "5min", "30min", "1h").
Window string `json:"window"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Platform string `json:"platform"`
GroupID *int64 `json:"group_id"`
QPS OpsRateSummary `json:"qps"`
TPS OpsRateSummary `json:"tps"`
}
...@@ -368,6 +368,9 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { ...@@ -368,6 +368,9 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
Aggregation: OpsAggregationSettings{ Aggregation: OpsAggregationSettings{
AggregationEnabled: false, AggregationEnabled: false,
}, },
IgnoreCountTokensErrors: false,
AutoRefreshEnabled: false,
AutoRefreshIntervalSec: 30,
} }
} }
...@@ -388,6 +391,10 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { ...@@ -388,6 +391,10 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 { if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 {
cfg.DataRetention.HourlyMetricsRetentionDays = 30 cfg.DataRetention.HourlyMetricsRetentionDays = 30
} }
// Normalize auto refresh interval (default 30 seconds)
if cfg.AutoRefreshIntervalSec <= 0 {
cfg.AutoRefreshIntervalSec = 30
}
} }
func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error { func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
...@@ -403,6 +410,9 @@ func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error { ...@@ -403,6 +410,9 @@ func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 { if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 {
return errors.New("hourly_metrics_retention_days must be between 1 and 365") return errors.New("hourly_metrics_retention_days must be between 1 and 365")
} }
if cfg.AutoRefreshIntervalSec < 15 || cfg.AutoRefreshIntervalSec > 300 {
return errors.New("auto_refresh_interval_seconds must be between 15 and 300")
}
return nil return nil
} }
...@@ -463,3 +473,93 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva ...@@ -463,3 +473,93 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva
_ = json.Unmarshal(raw, updated) _ = json.Unmarshal(raw, updated)
return updated, nil return updated, nil
} }
// =========================
// Metric thresholds
// =========================
const SettingKeyOpsMetricThresholds = "ops_metric_thresholds"
func defaultOpsMetricThresholds() *OpsMetricThresholds {
slaMin := 99.5
latencyMax := 2000.0
ttftMax := 500.0
reqErrMax := 5.0
upstreamErrMax := 5.0
return &OpsMetricThresholds{
SLAPercentMin: &slaMin,
LatencyP99MsMax: &latencyMax,
TTFTp99MsMax: &ttftMax,
RequestErrorRatePercentMax: &reqErrMax,
UpstreamErrorRatePercentMax: &upstreamErrMax,
}
}
func (s *OpsService) GetMetricThresholds(ctx context.Context) (*OpsMetricThresholds, error) {
defaultCfg := defaultOpsMetricThresholds()
if s == nil || s.settingRepo == nil {
return defaultCfg, nil
}
if ctx == nil {
ctx = context.Background()
}
raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMetricThresholds)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
if b, mErr := json.Marshal(defaultCfg); mErr == nil {
_ = s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(b))
}
return defaultCfg, nil
}
return nil, err
}
cfg := &OpsMetricThresholds{}
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
return defaultCfg, nil
}
return cfg, nil
}
func (s *OpsService) UpdateMetricThresholds(ctx context.Context, cfg *OpsMetricThresholds) (*OpsMetricThresholds, error) {
if s == nil || s.settingRepo == nil {
return nil, errors.New("setting repository not initialized")
}
if ctx == nil {
ctx = context.Background()
}
if cfg == nil {
return nil, errors.New("invalid config")
}
// Validate thresholds
if cfg.SLAPercentMin != nil && (*cfg.SLAPercentMin < 0 || *cfg.SLAPercentMin > 100) {
return nil, errors.New("sla_percent_min must be between 0 and 100")
}
if cfg.LatencyP99MsMax != nil && *cfg.LatencyP99MsMax < 0 {
return nil, errors.New("latency_p99_ms_max must be >= 0")
}
if cfg.TTFTp99MsMax != nil && *cfg.TTFTp99MsMax < 0 {
return nil, errors.New("ttft_p99_ms_max must be >= 0")
}
if cfg.RequestErrorRatePercentMax != nil && (*cfg.RequestErrorRatePercentMax < 0 || *cfg.RequestErrorRatePercentMax > 100) {
return nil, errors.New("request_error_rate_percent_max must be between 0 and 100")
}
if cfg.UpstreamErrorRatePercentMax != nil && (*cfg.UpstreamErrorRatePercentMax < 0 || *cfg.UpstreamErrorRatePercentMax > 100) {
return nil, errors.New("upstream_error_rate_percent_max must be between 0 and 100")
}
raw, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(raw)); err != nil {
return nil, err
}
updated := &OpsMetricThresholds{}
_ = json.Unmarshal(raw, updated)
return updated, nil
}
...@@ -61,17 +61,29 @@ type OpsAlertSilencingSettings struct { ...@@ -61,17 +61,29 @@ type OpsAlertSilencingSettings struct {
Entries []OpsAlertSilenceEntry `json:"entries,omitempty"` Entries []OpsAlertSilenceEntry `json:"entries,omitempty"`
} }
type OpsMetricThresholds struct {
SLAPercentMin *float64 `json:"sla_percent_min,omitempty"` // SLA低于此值变红
LatencyP99MsMax *float64 `json:"latency_p99_ms_max,omitempty"` // 延迟P99高于此值变红
TTFTp99MsMax *float64 `json:"ttft_p99_ms_max,omitempty"` // TTFT P99高于此值变红
RequestErrorRatePercentMax *float64 `json:"request_error_rate_percent_max,omitempty"` // 请求错误率高于此值变红
UpstreamErrorRatePercentMax *float64 `json:"upstream_error_rate_percent_max,omitempty"` // 上游错误率高于此值变红
}
type OpsAlertRuntimeSettings struct { type OpsAlertRuntimeSettings struct {
EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"` EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"`
DistributedLock OpsDistributedLockSettings `json:"distributed_lock"` DistributedLock OpsDistributedLockSettings `json:"distributed_lock"`
Silencing OpsAlertSilencingSettings `json:"silencing"` Silencing OpsAlertSilencingSettings `json:"silencing"`
Thresholds OpsMetricThresholds `json:"thresholds"` // 指标阈值配置
} }
// OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation). // OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation).
type OpsAdvancedSettings struct { type OpsAdvancedSettings struct {
DataRetention OpsDataRetentionSettings `json:"data_retention"` DataRetention OpsDataRetentionSettings `json:"data_retention"`
Aggregation OpsAggregationSettings `json:"aggregation"` Aggregation OpsAggregationSettings `json:"aggregation"`
IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"`
AutoRefreshEnabled bool `json:"auto_refresh_enabled"`
AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"`
} }
type OpsDataRetentionSettings struct { type OpsDataRetentionSettings struct {
......
...@@ -61,6 +61,7 @@ func (s *RateLimitService) SetSettingService(settingService *SettingService) { ...@@ -61,6 +61,7 @@ func (s *RateLimitService) SetSettingService(settingService *SettingService) {
func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) { func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) {
// apikey 类型账号:检查自定义错误码配置 // apikey 类型账号:检查自定义错误码配置
// 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载) // 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载)
customErrorCodesEnabled := account.IsCustomErrorCodesEnabled()
if !account.ShouldHandleErrorCode(statusCode) { if !account.ShouldHandleErrorCode(statusCode) {
log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode) log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode)
return false return false
...@@ -105,11 +106,19 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc ...@@ -105,11 +106,19 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
s.handle529(ctx, account) s.handle529(ctx, account)
shouldDisable = false shouldDisable = false
default: default:
// 其他5xx错误:记录但不停止调度 // 自定义错误码启用时:在列表中的错误码都应该停止调度
if statusCode >= 500 { if customErrorCodesEnabled {
msg := "Custom error code triggered"
if upstreamMsg != "" {
msg = upstreamMsg
}
s.handleCustomErrorCode(ctx, account, statusCode, msg)
shouldDisable = true
} else if statusCode >= 500 {
// 未启用自定义错误码时:仅记录5xx错误
log.Printf("Account %d received upstream error %d", account.ID, statusCode) log.Printf("Account %d received upstream error %d", account.ID, statusCode)
shouldDisable = false
} }
shouldDisable = false
} }
if tempMatched { if tempMatched {
...@@ -285,6 +294,16 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account ...@@ -285,6 +294,16 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg) log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg)
} }
// handleCustomErrorCode 处理自定义错误码,停止账号调度
func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *Account, statusCode int, errorMsg string) {
msg := "Custom error code " + strconv.Itoa(statusCode) + ": " + errorMsg
if err := s.accountRepo.SetError(ctx, account.ID, msg); err != nil {
log.Printf("SetError failed for account %d: %v", account.ID, err)
return
}
log.Printf("Account %d disabled due to custom error code %d: %s", account.ID, statusCode, errorMsg)
}
// handle429 处理429限流错误 // handle429 处理429限流错误
// 解析响应头获取重置时间,标记账号为限流状态 // 解析响应头获取重置时间,标记账号为限流状态
func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) { func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) {
......
-- Migration: 添加 is_count_tokens 字段到 ops_error_logs 表
-- Purpose: 标记 count_tokens 请求的错误,以便在统计和告警中根据配置动态过滤
-- Author: System
-- Date: 2026-01-12
-- Add is_count_tokens column to ops_error_logs table
ALTER TABLE ops_error_logs
ADD COLUMN is_count_tokens BOOLEAN NOT NULL DEFAULT FALSE;
-- Add comment
COMMENT ON COLUMN ops_error_logs.is_count_tokens IS '是否为 count_tokens 请求的错误(用于统计过滤)';
-- Create index for filtering (optional, improves query performance)
CREATE INDEX IF NOT EXISTS idx_ops_error_logs_is_count_tokens
ON ops_error_logs(is_count_tokens)
WHERE is_count_tokens = TRUE;
# 忽略编译后的文件
vite.config.js
vite.config.d.ts
# 忽略依赖
node_modules/
# 忽略构建输出
dist/
../backend/internal/web/dist/
# 忽略缓存
.cache/
.vite/
...@@ -362,6 +362,45 @@ export async function getAccountAvailabilityStats(platform?: string, groupId?: n ...@@ -362,6 +362,45 @@ export async function getAccountAvailabilityStats(platform?: string, groupId?: n
return data return data
} }
export interface OpsRateSummary {
current: number
peak: number
avg: number
}
export interface OpsRealtimeTrafficSummary {
window: string
start_time: string
end_time: string
platform: string
group_id?: number | null
qps: OpsRateSummary
tps: OpsRateSummary
}
export interface OpsRealtimeTrafficSummaryResponse {
enabled: boolean
summary: OpsRealtimeTrafficSummary | null
timestamp?: string
}
export async function getRealtimeTrafficSummary(
window: string,
platform?: string,
groupId?: number | null
): Promise<OpsRealtimeTrafficSummaryResponse> {
const params: Record<string, any> = { window }
if (platform) {
params.platform = platform
}
if (typeof groupId === 'number' && groupId > 0) {
params.group_id = groupId
}
const { data } = await apiClient.get<OpsRealtimeTrafficSummaryResponse>('/admin/ops/realtime-traffic', { params })
return data
}
/** /**
* Subscribe to realtime QPS updates via WebSocket. * Subscribe to realtime QPS updates via WebSocket.
* *
...@@ -661,6 +700,14 @@ export interface EmailNotificationConfig { ...@@ -661,6 +700,14 @@ export interface EmailNotificationConfig {
} }
} }
export interface OpsMetricThresholds {
sla_percent_min?: number | null // SLA低于此值变红
latency_p99_ms_max?: number | null // 延迟P99高于此值变红
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红
}
export interface OpsDistributedLockSettings { export interface OpsDistributedLockSettings {
enabled: boolean enabled: boolean
key: string key: string
...@@ -681,11 +728,15 @@ export interface OpsAlertRuntimeSettings { ...@@ -681,11 +728,15 @@ export interface OpsAlertRuntimeSettings {
reason: string reason: string
}> }>
} }
thresholds: OpsMetricThresholds // 指标阈值配置
} }
export interface OpsAdvancedSettings { export interface OpsAdvancedSettings {
data_retention: OpsDataRetentionSettings data_retention: OpsDataRetentionSettings
aggregation: OpsAggregationSettings aggregation: OpsAggregationSettings
ignore_count_tokens_errors: boolean
auto_refresh_enabled: boolean
auto_refresh_interval_seconds: number
} }
export interface OpsDataRetentionSettings { export interface OpsDataRetentionSettings {
...@@ -929,6 +980,17 @@ export async function updateAdvancedSettings(config: OpsAdvancedSettings): Promi ...@@ -929,6 +980,17 @@ export async function updateAdvancedSettings(config: OpsAdvancedSettings): Promi
return data return data
} }
// ==================== Metric Thresholds ====================
async function getMetricThresholds(): Promise<OpsMetricThresholds> {
const { data } = await apiClient.get<OpsMetricThresholds>('/admin/ops/settings/metric-thresholds')
return data
}
async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<void> {
await apiClient.put('/admin/ops/settings/metric-thresholds', thresholds)
}
export const opsAPI = { export const opsAPI = {
getDashboardOverview, getDashboardOverview,
getThroughputTrend, getThroughputTrend,
...@@ -937,6 +999,7 @@ export const opsAPI = { ...@@ -937,6 +999,7 @@ export const opsAPI = {
getErrorDistribution, getErrorDistribution,
getConcurrencyStats, getConcurrencyStats,
getAccountAvailabilityStats, getAccountAvailabilityStats,
getRealtimeTrafficSummary,
subscribeQPS, subscribeQPS,
listErrorLogs, listErrorLogs,
getErrorLogDetail, getErrorLogDetail,
...@@ -952,7 +1015,9 @@ export const opsAPI = { ...@@ -952,7 +1015,9 @@ export const opsAPI = {
getAlertRuntimeSettings, getAlertRuntimeSettings,
updateAlertRuntimeSettings, updateAlertRuntimeSettings,
getAdvancedSettings, getAdvancedSettings,
updateAdvancedSettings updateAdvancedSettings,
getMetricThresholds,
updateMetricThresholds
} }
export default opsAPI export default opsAPI
<template>
<div v-if="groups && groups.length > 0" class="relative max-w-56">
<!-- 分组容器:固定最大宽度,最多显示2行 -->
<div class="flex flex-wrap gap-1 max-h-14 overflow-hidden">
<GroupBadge
v-for="group in displayGroups"
:key="group.id"
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:show-rate="false"
class="max-w-24"
/>
<!-- 更多数量徽章 -->
<button
v-if="hiddenCount > 0"
ref="moreButtonRef"
@click.stop="showPopover = !showPopover"
class="inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500 transition-colors cursor-pointer whitespace-nowrap"
>
<span>+{{ hiddenCount }}</span>
</button>
</div>
<!-- Popover 显示完整列表 -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showPopover"
ref="popoverRef"
class="fixed z-50 min-w-48 max-w-96 rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-dark-600 dark:bg-dark-800"
:style="popoverStyle"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
</span>
<button
@click="showPopover = false"
class="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex flex-wrap gap-1.5 max-h-64 overflow-y-auto">
<GroupBadge
v-for="group in groups"
:key="group.id"
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:show-rate="false"
/>
</div>
</div>
</Transition>
</Teleport>
<!-- 点击外部关闭 popover -->
<div
v-if="showPopover"
class="fixed inset-0 z-40"
@click="showPopover = false"
/>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from '@/components/common/GroupBadge.vue'
import type { Group } from '@/types'
interface Props {
groups: Group[] | null | undefined
maxDisplay?: number
}
const props = withDefaults(defineProps<Props>(), {
maxDisplay: 4
})
const { t } = useI18n()
const moreButtonRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const showPopover = ref(false)
// 显示的分组(最多显示 maxDisplay 个)
const displayGroups = computed(() => {
if (!props.groups) return []
if (props.groups.length <= props.maxDisplay) {
return props.groups
}
// 留一个位置给 +N 按钮
return props.groups.slice(0, props.maxDisplay - 1)
})
// 隐藏的数量
const hiddenCount = computed(() => {
if (!props.groups) return 0
if (props.groups.length <= props.maxDisplay) return 0
return props.groups.length - (props.maxDisplay - 1)
})
// Popover 位置样式
const popoverStyle = computed(() => {
if (!moreButtonRef.value) return {}
const rect = moreButtonRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let top = rect.bottom + 8
let left = rect.left
// 如果下方空间不足,显示在上方
if (top + 280 > viewportHeight) {
top = Math.max(8, rect.top - 280)
}
// 如果右侧空间不足,向左偏移
if (left + 384 > viewportWidth) {
left = Math.max(8, viewportWidth - 392)
}
return {
top: `${top}px`,
left: `${left}px`
}
})
// 关闭 popover 的键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
showPopover.value = false
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
...@@ -778,6 +778,16 @@ const addPresetMapping = (from: string, to: string) => { ...@@ -778,6 +778,16 @@ const addPresetMapping = (from: string, to: string) => {
const toggleErrorCode = (code: number) => { const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code) const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) { if (index === -1) {
// Adding code - check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
} else { } else {
selectedErrorCodes.value.splice(index, 1) selectedErrorCodes.value.splice(index, 1)
...@@ -794,6 +804,16 @@ const addCustomErrorCode = () => { ...@@ -794,6 +804,16 @@ const addCustomErrorCode = () => {
appStore.showInfo(t('admin.accounts.errorCodeExists')) appStore.showInfo(t('admin.accounts.errorCodeExists'))
return return
} }
// Check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null customErrorCodeInput.value = null
} }
......
...@@ -1976,6 +1976,16 @@ const addPresetMapping = (from: string, to: string) => { ...@@ -1976,6 +1976,16 @@ const addPresetMapping = (from: string, to: string) => {
const toggleErrorCode = (code: number) => { const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code) const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) { if (index === -1) {
// Adding code - check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
} else { } else {
selectedErrorCodes.value.splice(index, 1) selectedErrorCodes.value.splice(index, 1)
...@@ -1993,6 +2003,16 @@ const addCustomErrorCode = () => { ...@@ -1993,6 +2003,16 @@ const addCustomErrorCode = () => {
appStore.showInfo(t('admin.accounts.errorCodeExists')) appStore.showInfo(t('admin.accounts.errorCodeExists'))
return return
} }
// Check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null customErrorCodeInput.value = null
} }
...@@ -2462,6 +2482,7 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2462,6 +2482,7 @@ const handleCookieAuth = async (sessionKey: string) => {
await adminAPI.accounts.create({ await adminAPI.accounts.create({
name: accountName, name: accountName,
notes: form.notes,
platform: form.platform, platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token' type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials, credentials,
...@@ -2469,6 +2490,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2469,6 +2490,8 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
}) })
......
...@@ -936,6 +936,16 @@ const addPresetMapping = (from: string, to: string) => { ...@@ -936,6 +936,16 @@ const addPresetMapping = (from: string, to: string) => {
const toggleErrorCode = (code: number) => { const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code) const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) { if (index === -1) {
// Adding code - check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
} else { } else {
selectedErrorCodes.value.splice(index, 1) selectedErrorCodes.value.splice(index, 1)
...@@ -953,6 +963,16 @@ const addCustomErrorCode = () => { ...@@ -953,6 +963,16 @@ const addCustomErrorCode = () => {
appStore.showInfo(t('admin.accounts.errorCodeExists')) appStore.showInfo(t('admin.accounts.errorCodeExists'))
return return
} }
// Check for 429/529 warning
if (code === 429) {
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
return
}
} else if (code === 529) {
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
return
}
}
selectedErrorCodes.value.push(code) selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null customErrorCodeInput.value = null
} }
......
...@@ -124,7 +124,8 @@ const icons = { ...@@ -124,7 +124,8 @@ const icons = {
chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z', chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z',
calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z', calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z' badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z',
brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z'
} as const } as const
const iconPath = computed(() => icons[props.name]) const iconPath = computed(() => icons[props.name])
......
...@@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => { ...@@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => {
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '') const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta` return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
})() })()
const geminiBase = (() => {
const trimmed = baseRoot.replace(/\/+$/, '')
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
})()
if (activeClientTab.value === 'opencode') { if (activeClientTab.value === 'opencode') {
switch (props.platform) { switch (props.platform) {
...@@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => { ...@@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => {
case 'openai': case 'openai':
return [generateOpenCodeConfig('openai', apiBase, apiKey)] return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini': case 'gemini':
return [generateOpenCodeConfig('gemini', apiBase, apiKey)] return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
case 'antigravity': case 'antigravity':
return [ return [
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'), generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
...@@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin ...@@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
[platform]: { [platform]: {
options: { options: {
baseURL: baseUrl, baseURL: baseUrl,
apiKey, apiKey
...(platform === 'openai' ? { store: false } : {})
} }
} }
} }
const openaiModels = { const openaiModels = {
'gpt-5.2-codex': { 'gpt-5.2-codex': {
name: 'GPT-5.2 Codex', name: 'GPT-5.2 Codex',
options: {
store: false
},
variants: { variants: {
low: {}, low: {},
medium: {}, medium: {},
...@@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin ...@@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
provider[platform].models = openaiModels provider[platform].models = openaiModels
} }
const agent =
platform === 'openai'
? {
build: {
options: {
store: false
}
},
plan: {
options: {
store: false
}
}
}
: undefined
const content = JSON.stringify( const content = JSON.stringify(
{ {
provider, provider,
...(agent ? { agent } : {}),
$schema: 'https://opencode.ai/config.json' $schema: 'https://opencode.ai/config.json'
}, },
null, null,
......
...@@ -13,7 +13,17 @@ const openaiModels = [ ...@@ -13,7 +13,17 @@ const openaiModels = [
'o1', 'o1-preview', 'o1-mini', 'o1-pro', 'o1', 'o1-preview', 'o1-mini', 'o1-pro',
'o3', 'o3-mini', 'o3-pro', 'o3', 'o3-mini', 'o3-pro',
'o4-mini', 'o4-mini',
'gpt-5', 'gpt-5-mini', 'gpt-5-nano', // GPT-5 系列(同步后端定价文件)
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
// GPT-5.1 系列
'gpt-5.1', 'gpt-5.1-2025-11-13', 'gpt-5.1-chat-latest',
'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
// GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
'chatgpt-4o-latest', 'chatgpt-4o-latest',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview' 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
] ]
...@@ -211,7 +221,10 @@ const openaiPresetMappings = [ ...@@ -211,7 +221,10 @@ const openaiPresetMappings = [
{ label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, { label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' } { label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
] ]
const geminiPresetMappings = [ const geminiPresetMappings = [
......
...@@ -156,6 +156,7 @@ export default { ...@@ -156,6 +156,7 @@ export default {
unknownError: 'Unknown error occurred', unknownError: 'Unknown error occurred',
saving: 'Saving...', saving: 'Saving...',
selectedCount: '({count} selected)', refresh: 'Refresh', selectedCount: '({count} selected)', refresh: 'Refresh',
settings: 'Settings',
notAvailable: 'N/A', notAvailable: 'N/A',
now: 'Now', now: 'Now',
unknown: 'Unknown', unknown: 'Unknown',
...@@ -389,7 +390,7 @@ export default { ...@@ -389,7 +390,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode Example', title: 'OpenCode Example',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: 'This is a group configuration example. Adjust model and options as needed.', hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.',
}, },
}, },
customKeyLabel: 'Custom Key', customKeyLabel: 'Custom Key',
...@@ -1021,6 +1022,7 @@ export default { ...@@ -1021,6 +1022,7 @@ export default {
schedulableEnabled: 'Scheduling enabled', schedulableEnabled: 'Scheduling enabled',
schedulableDisabled: 'Scheduling disabled', schedulableDisabled: 'Scheduling disabled',
failedToToggleSchedulable: 'Failed to toggle scheduling status', failedToToggleSchedulable: 'Failed to toggle scheduling status',
allGroups: '{count} groups total',
platforms: { platforms: {
anthropic: 'Anthropic', anthropic: 'Anthropic',
claude: 'Claude', claude: 'Claude',
...@@ -1203,6 +1205,10 @@ export default { ...@@ -1203,6 +1205,10 @@ export default {
customErrorCodesHint: 'Only stop scheduling for selected error codes', customErrorCodesHint: 'Only stop scheduling for selected error codes',
customErrorCodesWarning: customErrorCodesWarning:
'Only selected error codes will stop scheduling. Other errors will return 500.', 'Only selected error codes will stop scheduling. Other errors will return 500.',
customErrorCodes429Warning:
'429 already has built-in rate limit handling. Adding it to custom error codes will disable the account instead of temporary rate limiting. Are you sure?',
customErrorCodes529Warning:
'529 already has built-in overload handling. Adding it to custom error codes will disable the account instead of temporary overload marking. Are you sure?',
selectedErrorCodes: 'Selected', selectedErrorCodes: 'Selected',
noneSelectedUsesDefault: 'None selected (uses default policy)', noneSelectedUsesDefault: 'None selected (uses default policy)',
enterErrorCode: 'Enter error code (100-599)', enterErrorCode: 'Enter error code (100-599)',
...@@ -1902,6 +1908,7 @@ export default { ...@@ -1902,6 +1908,7 @@ export default {
max: 'max:', max: 'max:',
qps: 'QPS', qps: 'QPS',
requests: 'Requests', requests: 'Requests',
requestsTitle: 'Requests',
upstream: 'Upstream', upstream: 'Upstream',
client: 'Client', client: 'Client',
system: 'System', system: 'System',
...@@ -1936,6 +1943,9 @@ export default { ...@@ -1936,6 +1943,9 @@ export default {
'6h': 'Last 6 hours', '6h': 'Last 6 hours',
'24h': 'Last 24 hours' '24h': 'Last 24 hours'
}, },
fullscreen: {
enter: 'Enter Fullscreen'
},
diagnosis: { diagnosis: {
title: 'Smart Diagnosis', title: 'Smart Diagnosis',
footer: 'Automated diagnostic suggestions based on current metrics', footer: 'Automated diagnostic suggestions based on current metrics',
...@@ -2114,7 +2124,10 @@ export default { ...@@ -2114,7 +2124,10 @@ export default {
empty: 'No alert rules', empty: 'No alert rules',
loadFailed: 'Failed to load alert rules', loadFailed: 'Failed to load alert rules',
saveFailed: 'Failed to save alert rule', saveFailed: 'Failed to save alert rule',
saveSuccess: 'Alert rule saved successfully',
deleteFailed: 'Failed to delete alert rule', deleteFailed: 'Failed to delete alert rule',
deleteSuccess: 'Alert rule deleted successfully',
manage: 'Manage Alert Rules',
create: 'Create Rule', create: 'Create Rule',
createTitle: 'Create Alert Rule', createTitle: 'Create Alert Rule',
editTitle: 'Edit Alert Rule', editTitle: 'Edit Alert Rule',
...@@ -2297,6 +2310,54 @@ export default { ...@@ -2297,6 +2310,54 @@ export default {
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100' accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
} }
}, },
settings: {
title: 'Ops Monitoring Settings',
loadFailed: 'Failed to load settings',
saveSuccess: 'Ops monitoring settings saved successfully',
saveFailed: 'Failed to save settings',
dataCollection: 'Data Collection',
evaluationInterval: 'Evaluation Interval (seconds)',
evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default',
alertConfig: 'Alert Configuration',
enableAlert: 'Enable Alerts',
alertRecipients: 'Alert Recipient Emails',
emailPlaceholder: 'Enter email address',
recipientsHint: 'If empty, the system will use the first admin email as default recipient',
minSeverity: 'Minimum Severity',
reportConfig: 'Report Configuration',
enableReport: 'Enable Reports',
reportRecipients: 'Report Recipient Emails',
dailySummary: 'Daily Summary',
weeklySummary: 'Weekly Summary',
metricThresholds: 'Metric Thresholds',
metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red',
slaMinPercent: 'SLA Minimum Percentage',
slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)',
latencyP99MaxMs: 'Latency P99 Maximum (ms)',
latencyP99MaxMsHint: 'Latency P99 above this value will be displayed in red (default: 2000ms)',
ttftP99MaxMs: 'TTFT P99 Maximum (ms)',
ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)',
requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)',
requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)',
upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)',
upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)',
advancedSettings: 'Advanced Settings',
dataRetention: 'Data Retention Policy',
enableCleanup: 'Enable Data Cleanup',
cleanupSchedule: 'Cleanup Schedule (Cron)',
cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily',
errorLogRetentionDays: 'Error Log Retention Days',
minuteMetricsRetentionDays: 'Minute Metrics Retention Days',
hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days',
retentionDaysHint: 'Recommended 7-90 days, longer periods will consume more storage',
aggregation: 'Pre-aggregation Tasks',
enableAggregation: 'Enable Pre-aggregation',
aggregationHint: 'Pre-aggregation improves query performance for long time windows',
validation: {
title: 'Please fix the following issues',
retentionDaysRange: 'Retention days must be between 1-365 days'
}
},
concurrency: { concurrency: {
title: 'Concurrency / Queue', title: 'Concurrency / Queue',
byPlatform: 'By Platform', byPlatform: 'By Platform',
...@@ -2330,12 +2391,13 @@ export default { ...@@ -2330,12 +2391,13 @@ export default {
accountError: 'Error' accountError: 'Error'
}, },
tooltips: { tooltips: {
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
latencyHistogram: 'Latency distribution (duration_ms) for successful requests.', latencyHistogram: 'Latency distribution (duration_ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.', errorDistribution: 'Error distribution by status code.',
goroutines: goroutines:
'Number of Go runtime goroutines (lightweight threads). There is no absolute safe number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.', 'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.',
cpu: 'CPU usage percentage, showing system processor load.', cpu: 'CPU usage percentage, showing system processor load.',
memory: 'Memory usage, including used and total available memory.', memory: 'Memory usage, including used and total available memory.',
db: 'Database connection pool status, including active, idle, and waiting connections.', db: 'Database connection pool status, including active, idle, and waiting connections.',
...@@ -2345,6 +2407,7 @@ export default { ...@@ -2345,6 +2407,7 @@ export default {
tokens: 'Total number of tokens processed in the current time window.', tokens: 'Total number of tokens processed in the current time window.',
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).', sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
errors: 'Error statistics, including total errors, error rate, and upstream error rate.', errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).',
latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.', latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.',
ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.', ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.',
health: 'System health score (0-100), considering SLA, error rate, and resource usage.' health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
......
...@@ -387,7 +387,7 @@ export default { ...@@ -387,7 +387,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode 配置示例', title: 'OpenCode 配置示例',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: '示例仅用于演示分组配置,模型与选项可按需调整。', hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
}, },
}, },
customKeyLabel: '自定义密钥', customKeyLabel: '自定义密钥',
...@@ -1099,6 +1099,7 @@ export default { ...@@ -1099,6 +1099,7 @@ export default {
schedulableEnabled: '调度已开启', schedulableEnabled: '调度已开启',
schedulableDisabled: '调度已关闭', schedulableDisabled: '调度已关闭',
failedToToggleSchedulable: '切换调度状态失败', failedToToggleSchedulable: '切换调度状态失败',
allGroups: '共 {count} 个分组',
columns: { columns: {
name: '名称', name: '名称',
platformType: '平台/类型', platformType: '平台/类型',
...@@ -1339,6 +1340,10 @@ export default { ...@@ -1339,6 +1340,10 @@ export default {
customErrorCodes: '自定义错误码', customErrorCodes: '自定义错误码',
customErrorCodesHint: '仅对选中的错误码停止调度', customErrorCodesHint: '仅对选中的错误码停止调度',
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。', customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',
customErrorCodes429Warning:
'429 已有内置的限流处理机制。添加到自定义错误码后,将直接停止调度而非临时限流。确定要添加吗?',
customErrorCodes529Warning:
'529 已有内置的过载处理机制。添加到自定义错误码后,将直接停止调度而非临时标记过载。确定要添加吗?',
selectedErrorCodes: '已选择', selectedErrorCodes: '已选择',
noneSelectedUsesDefault: '未选择(使用默认策略)', noneSelectedUsesDefault: '未选择(使用默认策略)',
enterErrorCode: '输入错误码 (100-599)', enterErrorCode: '输入错误码 (100-599)',
...@@ -2018,7 +2023,7 @@ export default { ...@@ -2018,7 +2023,7 @@ export default {
ready: '就绪', ready: '就绪',
requestsTotal: '请求(总计)', requestsTotal: '请求(总计)',
slaScope: 'SLA 范围:', slaScope: 'SLA 范围:',
tokens: 'Token', tokens: 'Token',
tps: 'TPS', tps: 'TPS',
current: '当前', current: '当前',
peak: '峰值', peak: '峰值',
...@@ -2047,7 +2052,8 @@ export default { ...@@ -2047,7 +2052,8 @@ export default {
avg: 'avg', avg: 'avg',
max: 'max', max: 'max',
qps: 'QPS', qps: 'QPS',
requests: '请求', requests: '请求数',
requestsTitle: '请求',
upstream: '上游', upstream: '上游',
client: '客户端', client: '客户端',
system: '系统', system: '系统',
...@@ -2082,6 +2088,9 @@ export default { ...@@ -2082,6 +2088,9 @@ export default {
'6h': '近6小时', '6h': '近6小时',
'24h': '近24小时' '24h': '近24小时'
}, },
fullscreen: {
enter: '进入全屏'
},
diagnosis: { diagnosis: {
title: '智能诊断', title: '智能诊断',
footer: '基于当前指标的自动诊断建议', footer: '基于当前指标的自动诊断建议',
...@@ -2465,6 +2474,18 @@ export default { ...@@ -2465,6 +2474,18 @@ export default {
reportRecipients: '评估报告接收邮箱', reportRecipients: '评估报告接收邮箱',
dailySummary: '每日摘要', dailySummary: '每日摘要',
weeklySummary: '每周摘要', weeklySummary: '每周摘要',
metricThresholds: '指标阈值配置',
metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示',
slaMinPercent: 'SLA最低百分比',
slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)',
latencyP99MaxMs: '延迟P99最大值(毫秒)',
latencyP99MaxMsHint: '延迟P99高于此值时显示为红色(默认:2000ms)',
ttftP99MaxMs: 'TTFT P99最大值(毫秒)',
ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)',
requestErrorRateMaxPercent: '请求错误率最大值(%)',
requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)',
upstreamErrorRateMaxPercent: '上游错误率最大值(%)',
upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)',
advancedSettings: '高级设置', advancedSettings: '高级设置',
dataRetention: '数据保留策略', dataRetention: '数据保留策略',
enableCleanup: '启用数据清理', enableCleanup: '启用数据清理',
......
...@@ -19,7 +19,22 @@ ...@@ -19,7 +19,22 @@
@apply min-h-screen; @apply min-h-screen;
} }
/* 自定义滚动条 */ /* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover,
*:focus-within {
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.dark *:hover,
.dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply h-2 w-2; @apply h-2 w-2;
} }
...@@ -29,10 +44,15 @@ ...@@ -29,10 +44,15 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-300 dark:bg-dark-600; @apply rounded-full bg-transparent;
transition: background-color 0.2s ease;
}
*:hover::-webkit-scrollbar-thumb {
@apply bg-gray-300/50 dark:bg-dark-600/50;
} }
::-webkit-scrollbar-thumb:hover { *:hover::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-dark-500; @apply bg-gray-400 dark:bg-dark-500;
} }
......
...@@ -56,10 +56,7 @@ ...@@ -56,10 +56,7 @@
<AccountTodayStatsCell :account="row" /> <AccountTodayStatsCell :account="row" />
</template> </template>
<template #cell-groups="{ row }"> <template #cell-groups="{ row }">
<div v-if="row.groups && row.groups.length > 0" class="flex flex-wrap gap-1.5"> <AccountGroupsCell :groups="row.groups" :max-display="4" />
<GroupBadge v-for="group in row.groups" :key="group.id" :name="group.name" :platform="group.platform" :subscription-type="group.subscription_type" :rate-multiplier="group.rate_multiplier" :show-rate="false" />
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template> </template>
<template #cell-usage="{ row }"> <template #cell-usage="{ row }">
<AccountUsageCell :account="row" /> <AccountUsageCell :account="row" />
...@@ -145,7 +142,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue' ...@@ -145,7 +142,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue' import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatDateTime, formatRelativeTime } from '@/utils/format' import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
......
<template> <template>
<AppLayout> <component :is="isFullscreen ? 'div' : AppLayout" :class="isFullscreen ? 'flex min-h-screen flex-col justify-center bg-gray-50 dark:bg-dark-950' : ''">
<div class="space-y-6 pb-12"> <div :class="[isFullscreen ? 'p-4 md:p-6' : '', 'space-y-6 pb-12']">
<div <div
v-if="errorMessage" v-if="errorMessage"
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400" class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
...@@ -13,17 +13,16 @@ ...@@ -13,17 +13,16 @@
<OpsDashboardHeader <OpsDashboardHeader
v-else-if="opsEnabled" v-else-if="opsEnabled"
:overview="overview" :overview="overview"
:ws-status="wsStatus"
:ws-reconnect-in-ms="wsReconnectInMs"
:ws-has-data="wsHasData"
:real-time-qps="realTimeQPS"
:real-time-tps="realTimeTPS"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
:time-range="timeRange" :time-range="timeRange"
:query-mode="queryMode" :query-mode="queryMode"
:loading="loading" :loading="loading"
:last-updated="lastUpdated" :last-updated="lastUpdated"
:thresholds="metricThresholds"
:auto-refresh-enabled="autoRefreshEnabled"
:auto-refresh-countdown="autoRefreshCountdown"
:fullscreen="isFullscreen"
@update:time-range="onTimeRangeChange" @update:time-range="onTimeRangeChange"
@update:platform="onPlatformChange" @update:platform="onPlatformChange"
@update:group="onGroupChange" @update:group="onGroupChange"
...@@ -33,6 +32,8 @@ ...@@ -33,6 +32,8 @@
@open-error-details="openErrorDetails" @open-error-details="openErrorDetails"
@open-settings="showSettingsDialog = true" @open-settings="showSettingsDialog = true"
@open-alert-rules="showAlertRulesCard = true" @open-alert-rules="showAlertRulesCard = true"
@enter-fullscreen="enterFullscreen"
@exit-fullscreen="exitFullscreen"
/> />
<!-- Row: Concurrency + Throughput --> <!-- Row: Concurrency + Throughput -->
...@@ -47,6 +48,7 @@ ...@@ -47,6 +48,7 @@
:top-groups="throughputTrend?.top_groups ?? []" :top-groups="throughputTrend?.top_groups ?? []"
:loading="loadingTrend" :loading="loadingTrend"
:time-range="timeRange" :time-range="timeRange"
:fullscreen="isFullscreen"
@select-platform="handleThroughputSelectPlatform" @select-platform="handleThroughputSelectPlatform"
@select-group="handleThroughputSelectGroup" @select-group="handleThroughputSelectGroup"
@open-details="handleOpenRequestDetails" @open-details="handleOpenRequestDetails"
...@@ -74,54 +76,54 @@ ...@@ -74,54 +76,54 @@
<!-- Alert Events --> <!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" /> <OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
<!-- Settings Dialog --> <!-- Settings Dialog (hidden in fullscreen mode) -->
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="fetchData" /> <template v-if="!isFullscreen">
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
<!-- Alert Rules Dialog --> <BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false"> <OpsAlertRulesCard />
<OpsAlertRulesCard /> </BaseDialog>
</BaseDialog>
<OpsErrorDetailsModal <OpsErrorDetailsModal
:show="showErrorDetails" :show="showErrorDetails"
:time-range="timeRange" :time-range="timeRange"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
:error-type="errorDetailsType" :error-type="errorDetailsType"
@update:show="showErrorDetails = $event" @update:show="showErrorDetails = $event"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" /> <OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
<OpsRequestDetailsModal <OpsRequestDetailsModal
v-model="showRequestDetails" v-model="showRequestDetails"
:time-range="timeRange" :time-range="timeRange"
:preset="requestDetailsPreset" :preset="requestDetailsPreset"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
</template>
</div> </div>
</AppLayout> </component>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn, useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { import {
opsAPI, opsAPI,
OPS_WS_CLOSE_CODES,
type OpsWSStatus,
type OpsDashboardOverview, type OpsDashboardOverview,
type OpsErrorDistributionResponse, type OpsErrorDistributionResponse,
type OpsErrorTrendResponse, type OpsErrorTrendResponse,
type OpsLatencyHistogramResponse, type OpsLatencyHistogramResponse,
type OpsThroughputTrendResponse type OpsThroughputTrendResponse,
type OpsMetricThresholds
} from '@/api/admin/ops' } from '@/api/admin/ops'
import { useAdminSettingsStore, useAppStore } from '@/stores' import { useAdminSettingsStore, useAppStore } from '@/stores'
import OpsDashboardHeader from './components/OpsDashboardHeader.vue' import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
...@@ -166,19 +168,35 @@ const QUERY_KEYS = { ...@@ -166,19 +168,35 @@ const QUERY_KEYS = {
timeRange: 'tr', timeRange: 'tr',
platform: 'platform', platform: 'platform',
groupId: 'group_id', groupId: 'group_id',
queryMode: 'mode' queryMode: 'mode',
fullscreen: 'fullscreen'
} as const } as const
const isApplyingRouteQuery = ref(false) const isApplyingRouteQuery = ref(false)
const isSyncingRouteQuery = ref(false) const isSyncingRouteQuery = ref(false)
// WebSocket for realtime QPS/TPS // Fullscreen mode
const realTimeQPS = ref(0) const isFullscreen = computed(() => {
const realTimeTPS = ref(0) const val = route.query[QUERY_KEYS.fullscreen]
const wsStatus = ref<OpsWSStatus>('closed') return val === '1' || val === 'true'
const wsReconnectInMs = ref<number | null>(null) })
const wsHasData = ref(false)
let unsubscribeQPS: (() => void) | null = null function exitFullscreen() {
const nextQuery = { ...route.query }
delete nextQuery[QUERY_KEYS.fullscreen]
router.replace({ query: nextQuery })
}
function enterFullscreen() {
const nextQuery = { ...route.query, [QUERY_KEYS.fullscreen]: '1' }
router.replace({ query: nextQuery })
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isFullscreen.value) {
exitFullscreen()
}
}
let dashboardFetchController: AbortController | null = null let dashboardFetchController: AbortController | null = null
let dashboardFetchSeq = 0 let dashboardFetchSeq = 0
...@@ -199,50 +217,6 @@ function abortDashboardFetch() { ...@@ -199,50 +217,6 @@ function abortDashboardFetch() {
} }
} }
function stopQPSSubscription(options?: { resetMetrics?: boolean }) {
wsStatus.value = 'closed'
wsReconnectInMs.value = null
if (unsubscribeQPS) unsubscribeQPS()
unsubscribeQPS = null
if (options?.resetMetrics) {
realTimeQPS.value = 0
realTimeTPS.value = 0
wsHasData.value = false
}
}
function startQPSSubscription() {
stopQPSSubscription()
unsubscribeQPS = opsAPI.subscribeQPS(
(payload) => {
if (payload && typeof payload === 'object' && payload.type === 'qps_update' && payload.data) {
realTimeQPS.value = payload.data.qps || 0
realTimeTPS.value = payload.data.tps || 0
wsHasData.value = true
}
},
{
onStatusChange: (status) => {
wsStatus.value = status
if (status === 'connected') wsReconnectInMs.value = null
},
onReconnectScheduled: ({ delayMs }) => {
wsReconnectInMs.value = delayMs
},
onFatalClose: (event) => {
// Server-side feature flag says realtime is disabled; keep UI consistent and avoid reconnect loops.
if (event && event.code === OPS_WS_CLOSE_CODES.REALTIME_DISABLED) {
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
stopQPSSubscription({ resetMetrics: true })
}
},
// QPS updates may be sparse in idle periods; keep the timeout conservative.
staleTimeoutMs: 180_000
}
)
}
const readQueryString = (key: string): string => { const readQueryString = (key: string): string => {
const value = route.query[key] const value = route.query[key]
if (typeof value === 'string') return value if (typeof value === 'string') return value
...@@ -314,6 +288,7 @@ const syncQueryToRoute = useDebounceFn(async () => { ...@@ -314,6 +288,7 @@ const syncQueryToRoute = useDebounceFn(async () => {
}, 250) }, 250)
const overview = ref<OpsDashboardOverview | null>(null) const overview = ref<OpsDashboardOverview | null>(null)
const metricThresholds = ref<OpsMetricThresholds | null>(null)
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null) const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingTrend = ref(false) const loadingTrend = ref(false)
...@@ -343,6 +318,45 @@ const requestDetailsPreset = ref<OpsRequestDetailsPreset>({ ...@@ -343,6 +318,45 @@ const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
const showSettingsDialog = ref(false) const showSettingsDialog = ref(false)
const showAlertRulesCard = ref(false) const showAlertRulesCard = ref(false)
// Auto refresh settings
const autoRefreshEnabled = ref(false)
const autoRefreshIntervalMs = ref(30000) // default 30 seconds
const autoRefreshCountdown = ref(0)
// Auto refresh timer
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
() => {
if (autoRefreshEnabled.value && opsEnabled.value && !loading.value) {
fetchData()
}
},
autoRefreshIntervalMs,
{ immediate: false }
)
// Countdown timer (updates every second)
const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
() => {
if (autoRefreshEnabled.value && autoRefreshCountdown.value > 0) {
autoRefreshCountdown.value--
}
},
1000,
{ immediate: false }
)
// Load auto refresh settings from backend
async function loadAutoRefreshSettings() {
try {
const settings = await opsAPI.getAdvancedSettings()
autoRefreshEnabled.value = settings.auto_refresh_enabled
autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000
autoRefreshCountdown.value = settings.auto_refresh_interval_seconds
} catch (err) {
console.error('[OpsDashboard] Failed to load auto refresh settings', err)
}
}
function handleThroughputSelectPlatform(nextPlatform: string) { function handleThroughputSelectPlatform(nextPlatform: string) {
platform.value = nextPlatform || '' platform.value = nextPlatform || ''
groupId.value = null groupId.value = null
...@@ -376,6 +390,11 @@ function onTimeRangeChange(v: string | number | boolean | null) { ...@@ -376,6 +390,11 @@ function onTimeRangeChange(v: string | number | boolean | null) {
timeRange.value = v as TimeRange timeRange.value = v as TimeRange
} }
function onSettingsSaved() {
loadThresholds()
fetchData()
}
function onPlatformChange(v: string | number | boolean | null) { function onPlatformChange(v: string | number | boolean | null) {
platform.value = typeof v === 'string' ? v : '' platform.value = typeof v === 'string' ? v : ''
} }
...@@ -561,6 +580,10 @@ async function fetchData() { ...@@ -561,6 +580,10 @@ async function fetchData() {
]) ])
if (fetchSeq !== dashboardFetchSeq) return if (fetchSeq !== dashboardFetchSeq) return
lastUpdated.value = new Date() lastUpdated.value = new Date()
// Reset auto refresh countdown after successful fetch
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
}
} catch (err) { } catch (err) {
if (!isOpsDisabledError(err)) { if (!isOpsDisabledError(err)) {
console.error('[ops] failed to fetch dashboard data', err) console.error('[ops] failed to fetch dashboard data', err)
...@@ -609,37 +632,66 @@ watch( ...@@ -609,37 +632,66 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
// Fullscreen mode: listen for ESC key
window.addEventListener('keydown', handleKeydown)
await adminSettingsStore.fetch() await adminSettingsStore.fetch()
if (!adminSettingsStore.opsMonitoringEnabled) { if (!adminSettingsStore.opsMonitoringEnabled) {
await router.replace('/admin/settings') await router.replace('/admin/settings')
return return
} }
if (adminSettingsStore.opsRealtimeMonitoringEnabled) { // Load thresholds configuration
startQPSSubscription() loadThresholds()
} else {
stopQPSSubscription({ resetMetrics: true }) // Load auto refresh settings
} await loadAutoRefreshSettings()
if (opsEnabled.value) { if (opsEnabled.value) {
await fetchData() await fetchData()
} }
// Start auto refresh if enabled
if (autoRefreshEnabled.value) {
resumeAutoRefresh()
resumeCountdown()
}
}) })
async function loadThresholds() {
try {
const settings = await opsAPI.getAlertRuntimeSettings()
metricThresholds.value = settings.thresholds || null
} catch (err) {
console.warn('[OpsDashboard] Failed to load thresholds', err)
metricThresholds.value = null
}
}
onUnmounted(() => { onUnmounted(() => {
stopQPSSubscription() window.removeEventListener('keydown', handleKeydown)
abortDashboardFetch() abortDashboardFetch()
pauseAutoRefresh()
pauseCountdown()
}) })
watch( // Watch auto refresh settings changes
() => adminSettingsStore.opsRealtimeMonitoringEnabled, watch(autoRefreshEnabled, (enabled) => {
(enabled) => { if (enabled) {
if (!opsEnabled.value) return autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
if (enabled) { resumeAutoRefresh()
startQPSSubscription() resumeCountdown()
} else { } else {
stopQPSSubscription({ resetMetrics: true }) pauseAutoRefresh()
} pauseCountdown()
autoRefreshCountdown.value = 0
} }
) })
// Reload auto refresh settings after settings dialog is closed
watch(showSettingsDialog, async (show) => {
if (!show) {
await loadAutoRefreshSettings()
}
})
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment