diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index a031b4a2..6a513dc1 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -1,6 +1,8 @@ package handler import ( + "time" + "github.com/Wei-Shaw/sub2api/internal/handler/admin" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -22,15 +24,26 @@ func NewChannelMonitorUserHandler(monitorService *service.ChannelMonitorService) // --- Response --- type channelMonitorUserListItem struct { - ID int64 `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - GroupName string `json:"group_name"` - PrimaryModel string `json:"primary_model"` - PrimaryStatus string `json:"primary_status"` - PrimaryLatencyMs *int `json:"primary_latency_ms"` - Availability7d float64 `json:"availability_7d"` - ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"` + ID int64 `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + GroupName string `json:"group_name"` + PrimaryModel string `json:"primary_model"` + PrimaryStatus string `json:"primary_status"` + PrimaryLatencyMs *int `json:"primary_latency_ms"` + PrimaryPingLatencyMs *int `json:"primary_ping_latency_ms"` + Availability7d float64 `json:"availability_7d"` + ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"` + Timeline []channelMonitorUserTimelinePoint `json:"timeline"` +} + +// channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。 +// 仅用于用户视图 list 响应,admin 视图不使用。 +type channelMonitorUserTimelinePoint struct { + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + CheckedAt string `json:"checked_at"` } type channelMonitorUserDetailResponse struct { @@ -60,16 +73,27 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte LatencyMs: e.LatencyMs, }) } + timeline := make([]channelMonitorUserTimelinePoint, 0, len(v.Timeline)) + for _, p := range v.Timeline { + timeline = append(timeline, channelMonitorUserTimelinePoint{ + Status: p.Status, + LatencyMs: p.LatencyMs, + PingLatencyMs: p.PingLatencyMs, + CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339), + }) + } return channelMonitorUserListItem{ - ID: v.ID, - Name: v.Name, - Provider: v.Provider, - GroupName: v.GroupName, - PrimaryModel: v.PrimaryModel, - PrimaryStatus: v.PrimaryStatus, - PrimaryLatencyMs: v.PrimaryLatencyMs, - Availability7d: v.Availability7d, - ExtraModels: extras, + ID: v.ID, + Name: v.Name, + Provider: v.Provider, + GroupName: v.GroupName, + PrimaryModel: v.PrimaryModel, + PrimaryStatus: v.PrimaryStatus, + PrimaryLatencyMs: v.PrimaryLatencyMs, + PrimaryPingLatencyMs: v.PrimaryPingLatencyMs, + Availability7d: v.Availability7d, + ExtraModels: extras, + Timeline: timeline, } } diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index b943f33c..cf5e1a93 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -243,7 +243,7 @@ func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID in func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monitorID int64) ([]*service.ChannelMonitorLatest, error) { const q = ` SELECT DISTINCT ON (model) - model, status, latency_ms, checked_at + model, status, latency_ms, ping_latency_ms, checked_at FROM channel_monitor_histories WHERE monitor_id = $1 ORDER BY model, checked_at DESC @@ -257,19 +257,27 @@ func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monit out := make([]*service.ChannelMonitorLatest, 0) for rows.Next() { l := &service.ChannelMonitorLatest{} - var latency sql.NullInt64 - if err := rows.Scan(&l.Model, &l.Status, &latency, &l.CheckedAt); err != nil { + var latency, ping sql.NullInt64 + if err := rows.Scan(&l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil { return nil, fmt.Errorf("scan latest row: %w", err) } - if latency.Valid { - v := int(latency.Int64) - l.LatencyMs = &v - } + assignNullInt(&l.LatencyMs, latency) + assignNullInt(&l.PingLatencyMs, ping) out = append(out, l) } return out, rows.Err() } +// assignNullInt 把 sql.NullInt64 解包到 *int 指针目标(valid 才分配新 int)。 +// 集中实现避免 latency / ping 两处重复 if latency.Valid { v := int(...) ... } 模板。 +func assignNullInt(dst **int, n sql.NullInt64) { + if !n.Valid { + return + } + v := int(n.Int64) + *dst = &v +} + // ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。 // "可用" = status IN (operational, degraded)。 func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) { @@ -338,7 +346,7 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, } const q = ` SELECT DISTINCT ON (monitor_id, model) - monitor_id, model, status, latency_ms, checked_at + monitor_id, model, status, latency_ms, ping_latency_ms, checked_at FROM channel_monitor_histories WHERE monitor_id = ANY($1) ORDER BY monitor_id, model, checked_at DESC @@ -352,14 +360,12 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, for rows.Next() { var monitorID int64 l := &service.ChannelMonitorLatest{} - var latency sql.NullInt64 - if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &l.CheckedAt); err != nil { + var latency, ping sql.NullInt64 + if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil { return nil, fmt.Errorf("scan latest batch row: %w", err) } - if latency.Valid { - v := int(latency.Int64) - l.LatencyMs = &v - } + assignNullInt(&l.LatencyMs, latency) + assignNullInt(&l.PingLatencyMs, ping) out[monitorID] = append(out[monitorID], l) } if err := rows.Err(); err != nil { @@ -368,6 +374,107 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, return out, nil } +// ListRecentHistoryForMonitors 为多个 monitor 批量取各自"指定模型"最近 N 条历史(按 checked_at DESC,最新在前)。 +// primaryModels[monitorID] 指定该监控要过滤的模型名;monitor 不在 primaryModels 中的记录不返回。 +// 通过 CTE + unnest(两个 int8/text 数组) 构造 (monitor_id, model) 白名单, +// 再用 ROW_NUMBER() OVER (PARTITION BY monitor_id) 取各自前 N 条。 +// +// 返回值:map[monitorID] -> []*ChannelMonitorHistoryEntry(不含 message,减少网络开销)。 +// 空 ids / 空 primaryModels 返回空 map,不报错。 +func (r *channelMonitorRepository) ListRecentHistoryForMonitors( + ctx context.Context, + ids []int64, + primaryModels map[int64]string, + perMonitorLimit int, +) (map[int64][]*service.ChannelMonitorHistoryEntry, error) { + out := make(map[int64][]*service.ChannelMonitorHistoryEntry, len(ids)) + pairIDs, pairModels := buildMonitorModelPairs(ids, primaryModels) + if len(pairIDs) == 0 { + return out, nil + } + perMonitorLimit = clampTimelineLimit(perMonitorLimit) + + const q = ` + WITH targets AS ( + SELECT unnest($1::bigint[]) AS monitor_id, + unnest($2::text[]) AS model + ), + ranked AS ( + SELECT h.monitor_id, + h.status, + h.latency_ms, + h.ping_latency_ms, + h.checked_at, + ROW_NUMBER() OVER (PARTITION BY h.monitor_id ORDER BY h.checked_at DESC) AS rn + FROM channel_monitor_histories h + JOIN targets t + ON t.monitor_id = h.monitor_id AND t.model = h.model + ) + SELECT monitor_id, status, latency_ms, ping_latency_ms, checked_at + FROM ranked + WHERE rn <= $3 + ORDER BY monitor_id, checked_at DESC + ` + rows, err := r.db.QueryContext(ctx, q, pq.Array(pairIDs), pq.Array(pairModels), perMonitorLimit) + if err != nil { + return nil, fmt.Errorf("query recent history batch: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var monitorID int64 + entry := &service.ChannelMonitorHistoryEntry{} + var latency, ping sql.NullInt64 + if err := rows.Scan(&monitorID, &entry.Status, &latency, &ping, &entry.CheckedAt); err != nil { + return nil, fmt.Errorf("scan recent history row: %w", err) + } + assignNullInt(&entry.LatencyMs, latency) + assignNullInt(&entry.PingLatencyMs, ping) + out[monitorID] = append(out[monitorID], entry) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// buildMonitorModelPairs 基于 ids 过滤出有效的 (monitor_id, model) 对,model 为空时跳过。 +// 保证两个数组长度一致且一一对应,供 unnest 展开。 +func buildMonitorModelPairs(ids []int64, primaryModels map[int64]string) ([]int64, []string) { + if len(ids) == 0 || len(primaryModels) == 0 { + return nil, nil + } + pairIDs := make([]int64, 0, len(ids)) + pairModels := make([]string, 0, len(ids)) + for _, id := range ids { + model, ok := primaryModels[id] + if !ok || strings.TrimSpace(model) == "" { + continue + } + pairIDs = append(pairIDs, id) + pairModels = append(pairModels, model) + } + return pairIDs, pairModels +} + +// timelineLimit* 批量 timeline 查询的 perMonitorLimit 夹紧范围。 +// 下限 1 表示至少返回最近一条;上限 200 控制单次响应体与 SQL 内存占用(ROW_NUMBER 窗口上限)。 +const ( + timelineLimitMin = 1 + timelineLimitMax = 200 +) + +// clampTimelineLimit 把 perMonitorLimit 夹紧到 [timelineLimitMin, timelineLimitMax],避免非法值或超大查询。 +func clampTimelineLimit(n int) int { + if n < timelineLimitMin { + return timelineLimitMin + } + if n > timelineLimitMax { + return timelineLimitMax + } + return n +} + // ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。 func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) { out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids)) diff --git a/backend/internal/service/channel_monitor_aggregator.go b/backend/internal/service/channel_monitor_aggregator.go index 97015b40..09020f5f 100644 --- a/backend/internal/service/channel_monitor_aggregator.go +++ b/backend/internal/service/channel_monitor_aggregator.go @@ -49,7 +49,12 @@ func (s *ChannelMonitorService) BatchMonitorStatusSummary( } // ListUserView 用户只读视图:列出所有 enabled 监控的概览。 -// 使用批量聚合接口避免 N+1:1 次查 monitors,1 次查 latest(所有 monitor),1 次查 availability。 +// 使用批量聚合接口避免 N+1: +// +// 1 次查 monitors; +// 1 次批量 latest(含 ping_latency_ms); +// 1 次批量 7d availability; +// 1 次批量 timeline(主模型最近 N 条)。 func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonitorView, error) { monitors, err := s.repo.ListEnabled(ctx) if err != nil { @@ -59,6 +64,21 @@ func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonito return []*UserMonitorView{}, nil } + ids, primaryByID, extrasByID := collectMonitorIndexes(monitors) + summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID) + latestMap := s.batchLatest(ctx, ids) + timelineMap := s.batchTimeline(ctx, ids, primaryByID) + + views := make([]*UserMonitorView, 0, len(monitors)) + for _, m := range monitors { + primaryLatest := pickLatest(latestMap[m.ID], m.PrimaryModel) + views = append(views, buildUserViewFromSummary(m, summaries[m.ID], primaryLatest, timelineMap[m.ID])) + } + return views, nil +} + +// collectMonitorIndexes 把 monitors 列表按 ID 展开为聚合查询所需的三个索引结构。 +func collectMonitorIndexes(monitors []*ChannelMonitor) ([]int64, map[int64]string, map[int64][]string) { ids := make([]int64, 0, len(monitors)) primaryByID := make(map[int64]string, len(monitors)) extrasByID := make(map[int64][]string, len(monitors)) @@ -67,14 +87,44 @@ func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonito primaryByID[m.ID] = m.PrimaryModel extrasByID[m.ID] = m.ExtraModels } - summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID) + return ids, primaryByID, extrasByID +} - views := make([]*UserMonitorView, 0, len(monitors)) - for _, m := range monitors { - summary := summaries[m.ID] - views = append(views, buildUserViewFromSummary(m, summary)) +// batchLatest 批量取 latest per model,失败仅日志(与现有 BatchMonitorStatusSummary 一致,不阻断列表渲染)。 +func (s *ChannelMonitorService) batchLatest(ctx context.Context, ids []int64) map[int64][]*ChannelMonitorLatest { + latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids) + if err != nil { + slog.Warn("channel_monitor: user view batch latest failed", "error", err) + return map[int64][]*ChannelMonitorLatest{} } - return views, nil + return latestMap +} + +// batchTimeline 批量取每个 monitor 主模型最近 monitorTimelineMaxPoints 条历史。 +func (s *ChannelMonitorService) batchTimeline( + ctx context.Context, + ids []int64, + primaryByID map[int64]string, +) map[int64][]*ChannelMonitorHistoryEntry { + timelineMap, err := s.repo.ListRecentHistoryForMonitors(ctx, ids, primaryByID, monitorTimelineMaxPoints) + if err != nil { + slog.Warn("channel_monitor: user view batch timeline failed", "error", err) + return map[int64][]*ChannelMonitorHistoryEntry{} + } + return timelineMap +} + +// pickLatest 从 latest 切片中挑出指定 model 对应项,未命中返回 nil。 +func pickLatest(rows []*ChannelMonitorLatest, model string) *ChannelMonitorLatest { + if model == "" { + return nil + } + for _, r := range rows { + if r.Model == model { + return r + } + } + return nil } // GetUserDetail 用户只读视图:单个监控详情(每个模型 7d/15d/30d 可用率与平均延迟)。 @@ -170,9 +220,15 @@ func buildStatusSummary( return summary } -// buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary 装填 UserMonitorView(无 IO)。 -func buildUserViewFromSummary(m *ChannelMonitor, summary MonitorStatusSummary) *UserMonitorView { - return &UserMonitorView{ +// buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary + 主模型 latest + timeline 装填 UserMonitorView(无 IO)。 +// primaryLatest 可能为 nil(该监控尚无历史);timelineEntries 可能为空。 +func buildUserViewFromSummary( + m *ChannelMonitor, + summary MonitorStatusSummary, + primaryLatest *ChannelMonitorLatest, + timelineEntries []*ChannelMonitorHistoryEntry, +) *UserMonitorView { + view := &UserMonitorView{ ID: m.ID, Name: m.Name, Provider: m.Provider, @@ -182,7 +238,26 @@ func buildUserViewFromSummary(m *ChannelMonitor, summary MonitorStatusSummary) * PrimaryLatencyMs: summary.PrimaryLatencyMs, Availability7d: summary.Availability7d, ExtraModels: summary.ExtraModels, + Timeline: buildTimelinePoints(timelineEntries), } + if primaryLatest != nil { + view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs + } + return view +} + +// buildTimelinePoints 把 history entry 裁剪为 timeline 点(去除 message/ID/Model,减小响应体)。 +func buildTimelinePoints(entries []*ChannelMonitorHistoryEntry) []UserMonitorTimelinePoint { + out := make([]UserMonitorTimelinePoint, 0, len(entries)) + for _, e := range entries { + out = append(out, UserMonitorTimelinePoint{ + Status: e.Status, + LatencyMs: e.LatencyMs, + PingLatencyMs: e.PingLatencyMs, + CheckedAt: e.CheckedAt, + }) + } + return out } // mergeModelDetails 合并 latest + availability 三个窗口为 ModelDetail 列表。 diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index b4c02bcb..7255e4be 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -65,6 +65,9 @@ const ( // MonitorHistoryMaxLimit 历史查询最大返回条数(handler 层共享)。 MonitorHistoryMaxLimit = 1000 + // monitorTimelineMaxPoints 用户视图 timeline 每个监控最多返回的历史点数。 + monitorTimelineMaxPoints = 60 + // monitorEndpointResolveTimeout validateEndpoint 解析 hostname 的最长耗时。 monitorEndpointResolveTimeout = 5 * time.Second diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index b179e50c..957ace15 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -38,6 +38,9 @@ type ChannelMonitorRepository interface { // 批量聚合(admin/user list 用,避免 N+1) ListLatestForMonitorIDs(ctx context.Context, ids []int64) (map[int64][]*ChannelMonitorLatest, error) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*ChannelMonitorAvailability, error) + // ListRecentHistoryForMonitors 批量取多个 monitor 各自主模型(primaryModels[monitorID])最近 perMonitorLimit 条历史。 + // 返回的 entry 已按 checked_at DESC 排序(最新在前),不含 message 字段。 + ListRecentHistoryForMonitors(ctx context.Context, ids []int64, primaryModels map[int64]string, perMonitorLimit int) (map[int64][]*ChannelMonitorHistoryEntry, error) } // ChannelMonitorService 渠道监控管理服务。 diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index 4b34d8af..739c82fb 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -72,15 +72,25 @@ type CheckResult struct { // UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。 type UserMonitorView struct { - ID int64 - Name string - Provider string - GroupName string - PrimaryModel string - PrimaryStatus string - PrimaryLatencyMs *int - Availability7d float64 // 0-100 - ExtraModels []ExtraModelStatus + ID int64 + Name string + Provider string + GroupName string + PrimaryModel string + PrimaryStatus string + PrimaryLatencyMs *int + PrimaryPingLatencyMs *int // 主模型最近一次 ping 延迟 + Availability7d float64 // 0-100 + ExtraModels []ExtraModelStatus + Timeline []UserMonitorTimelinePoint // 主模型最近 N 个历史点(按 checked_at DESC,最新在前) +} + +// UserMonitorTimelinePoint 用户视图 timeline 单点数据(去除 message 以减小响应体)。 +type UserMonitorTimelinePoint struct { + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + CheckedAt time.Time `json:"checked_at"` } // ExtraModelStatus 附加模型最近一次状态。 @@ -134,10 +144,11 @@ type ChannelMonitorHistoryEntry struct { // ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。 type ChannelMonitorLatest struct { - Model string - Status string - LatencyMs *int - CheckedAt time.Time + Model string + Status string + LatencyMs *int + PingLatencyMs *int + CheckedAt time.Time } // ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。 diff --git a/frontend/src/api/channelMonitor.ts b/frontend/src/api/channelMonitor.ts index c5481636..38dd0c99 100644 --- a/frontend/src/api/channelMonitor.ts +++ b/frontend/src/api/channelMonitor.ts @@ -14,6 +14,13 @@ export interface UserMonitorExtraModel { latency_ms: number | null } +export interface MonitorTimelinePoint { + status: MonitorStatus + latency_ms: number | null + ping_latency_ms: number | null + checked_at: string +} + export interface UserMonitorView { id: number name: string @@ -22,8 +29,10 @@ export interface UserMonitorView { primary_model: string primary_status: MonitorStatus primary_latency_ms: number | null + primary_ping_latency_ms: number | null availability_7d: number extra_models: UserMonitorExtraModel[] + timeline: MonitorTimelinePoint[] } export interface UserMonitorListResponse { diff --git a/frontend/src/components/user/MonitorPrimaryModelCell.vue b/frontend/src/components/user/MonitorPrimaryModelCell.vue deleted file mode 100644 index 32620b2a..00000000 --- a/frontend/src/components/user/MonitorPrimaryModelCell.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/src/components/user/monitor/MonitorAvailabilityRow.vue b/frontend/src/components/user/monitor/MonitorAvailabilityRow.vue new file mode 100644 index 00000000..34420c9d --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorAvailabilityRow.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/user/monitor/MonitorCard.vue b/frontend/src/components/user/monitor/MonitorCard.vue new file mode 100644 index 00000000..33742c6d --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorCard.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/components/user/monitor/MonitorCardGrid.vue b/frontend/src/components/user/monitor/MonitorCardGrid.vue new file mode 100644 index 00000000..c7d24c01 --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorCardGrid.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/components/user/monitor/MonitorHero.vue b/frontend/src/components/user/monitor/MonitorHero.vue new file mode 100644 index 00000000..be5a96b8 --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorHero.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/components/user/monitor/MonitorMetricPair.vue b/frontend/src/components/user/monitor/MonitorMetricPair.vue new file mode 100644 index 00000000..0f3fd3dc --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorMetricPair.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/components/user/monitor/MonitorTimeline.vue b/frontend/src/components/user/monitor/MonitorTimeline.vue new file mode 100644 index 00000000..b4d0c151 --- /dev/null +++ b/frontend/src/components/user/monitor/MonitorTimeline.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/src/components/user/monitor/ProviderIcon.vue b/frontend/src/components/user/monitor/ProviderIcon.vue new file mode 100644 index 00000000..20456a2c --- /dev/null +++ b/frontend/src/components/user/monitor/ProviderIcon.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/composables/useChannelMonitorFormat.ts b/frontend/src/composables/useChannelMonitorFormat.ts index fbb310fa..7ffdaa42 100644 --- a/frontend/src/composables/useChannelMonitorFormat.ts +++ b/frontend/src/composables/useChannelMonitorFormat.ts @@ -4,6 +4,7 @@ * Centralises: * - status / provider label + badge class lookups * - latency / availability / percent number formatting + * - dashboard-style helpers (HSL for availability, provider gradient, relative time) * * i18n keys live under `monitorCommon.*` so admin and user views share the * same translation source. @@ -23,6 +24,11 @@ import { const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300' +/** Availability HSL hue multiplier: 0%=red(0) / 50%=yellow(60) / 100%=green(120). */ +const HSL_HUE_PER_PERCENT = 1.2 +const HSL_SATURATION = 72 +const HSL_LIGHTNESS = 42 + export interface AvailabilityRow { primary_status: MonitorStatus | '' availability_7d: number | null | undefined @@ -39,11 +45,11 @@ export function useChannelMonitorFormat() { function statusBadgeClass(s: MonitorStatus | ''): string { switch (s) { case STATUS_OPERATIONAL: - return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' + return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300' case STATUS_DEGRADED: - return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300' + return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300' case STATUS_FAILED: - return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' + return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300' case STATUS_ERROR: default: return NEUTRAL_BADGE @@ -60,11 +66,11 @@ export function useChannelMonitorFormat() { function providerBadgeClass(p: Provider | string): string { switch (p) { case PROVIDER_OPENAI: - return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' + return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300' case PROVIDER_ANTHROPIC: - return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300' + return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300' case PROVIDER_GEMINI: - return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' + return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300' default: return NEUTRAL_BADGE } @@ -85,6 +91,20 @@ export function useChannelMonitorFormat() { return formatPercent(row.availability_7d) } + function formatRelativeTime(iso: string | null | undefined): string { + if (!iso) return t('monitorCommon.latencyEmpty') + const ts = Date.parse(iso) + if (Number.isNaN(ts)) return t('monitorCommon.latencyEmpty') + const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000)) + if (diffSec < 60) return t('monitorCommon.relativeSecondsAgo', { n: diffSec }) + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return t('monitorCommon.relativeMinutesAgo', { n: diffMin }) + const diffHour = Math.floor(diffMin / 60) + if (diffHour < 24) return t('monitorCommon.relativeHoursAgo', { n: diffHour }) + const diffDay = Math.floor(diffHour / 24) + return t('monitorCommon.relativeDaysAgo', { n: diffDay }) + } + return { statusLabel, statusBadgeClass, @@ -93,5 +113,33 @@ export function useChannelMonitorFormat() { formatLatency, formatPercent, formatAvailability, + formatRelativeTime, + } +} + +/** + * Map availability percent to an HSL colour (red -> yellow -> green). + * Returns undefined for null/NaN so callers can fall back to a neutral colour. + */ +export function hslForPct(pct: number | null | undefined): string | undefined { + if (pct === null || pct === undefined || Number.isNaN(pct)) return undefined + const clamped = Math.max(0, Math.min(100, pct)) + const hue = clamped * HSL_HUE_PER_PERCENT + return `hsl(${hue} ${HSL_SATURATION}% ${HSL_LIGHTNESS}%)` +} + +/** + * Tailwind gradient class for the provider icon tile background. + */ +export function providerGradient(provider: string): string { + switch (provider) { + case PROVIDER_OPENAI: + return 'bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-500/10 dark:to-emerald-500/20' + case PROVIDER_ANTHROPIC: + return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20' + case PROVIDER_GEMINI: + return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20' + default: + return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600' } } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 32fbce19..b95c8b44 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -867,7 +867,22 @@ export default { }, extraModelsHeader: 'Extra Models', extraModelsEmpty: 'No extra models', - latencyEmpty: '-' + latencyEmpty: '-', + availabilityPrefix: 'Availability', + dialogLatency: 'Dialog Latency', + endpointPing: 'Endpoint PING', + history60pts: 'HISTORY ({n} PTS)', + nextUpdateIn: 'NEXT UPDATE IN {n}s', + past: 'PAST', + now: 'NOW', + maintenancePaused: 'Maintenance · timeline paused', + extraModelsCount: '+ {n} models', + pollEvery: '{n}s polling', + updatedAt: 'Updated {time}', + relativeSecondsAgo: '{n}s ago', + relativeMinutesAgo: '{n}m ago', + relativeHoursAgo: '{n}h ago', + relativeDaysAgo: '{n}d ago' }, // Channel Status (user-facing read-only view) @@ -880,6 +895,22 @@ export default { detailLoadError: 'Failed to load channel detail', detailTitle: 'Channel Detail', closeDetail: 'Close', + hero: { + breadcrumb: 'CHANNEL · STATUS', + title: 'INTELLIGENCE MONITOR', + subtitleZh: 'Real-time tracking of availability, latency and status for leading AI endpoints.', + subtitleEn: 'Advanced performance metrics for next-gen intelligence.' + }, + windowTab: { + '7d': '7 days', + '15d': '15 days', + '30d': '30 days' + }, + overall: { + operational: 'OPERATIONAL', + degraded: 'DEGRADED', + unavailable: 'UNAVAILABLE' + }, columns: { name: 'Name', provider: 'Provider', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index dd3af363..54bc03c5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -871,7 +871,22 @@ export default { }, extraModelsHeader: '附加模型', extraModelsEmpty: '无附加模型', - latencyEmpty: '-' + latencyEmpty: '-', + availabilityPrefix: '可用性', + dialogLatency: '对话延迟', + endpointPing: '端点 PING', + history60pts: '近 {n} 次记录', + nextUpdateIn: '{n}s 后刷新', + past: 'PAST', + now: 'NOW', + maintenancePaused: '维护中 · 已暂停时间线采集', + extraModelsCount: '+ {n} 模型', + pollEvery: '{n}s 轮询', + updatedAt: '更新于 {time}', + relativeSecondsAgo: '{n} 秒前', + relativeMinutesAgo: '{n} 分钟前', + relativeHoursAgo: '{n} 小时前', + relativeDaysAgo: '{n} 天前' }, // Channel Status (user-facing read-only view) @@ -884,6 +899,22 @@ export default { detailLoadError: '加载渠道详情失败', detailTitle: '渠道详情', closeDetail: '关闭', + hero: { + breadcrumb: '渠道 · 状态', + title: 'INTELLIGENCE MONITOR', + subtitleZh: '实时追踪各大 AI 模型对话接口的可用性、延迟与官方服务状态。', + subtitleEn: 'Advanced performance metrics for next-gen intelligence.' + }, + windowTab: { + '7d': '7 天', + '15d': '15 天', + '30d': '30 天' + }, + overall: { + operational: 'OPERATIONAL', + degraded: 'DEGRADED', + unavailable: 'UNAVAILABLE' + }, columns: { name: '名称', provider: '供应商', diff --git a/frontend/src/views/user/ChannelStatusView.vue b/frontend/src/views/user/ChannelStatusView.vue index 9f5fe8d1..af427cca 100644 --- a/frontend/src/views/user/ChannelStatusView.vue +++ b/frontend/src/views/user/ChannelStatusView.vue @@ -1,93 +1,23 @@