feat(channel-monitor): redesign user dashboard as card grid

Reference check-cx UI: INTELLIGENCE MONITOR hero + 3-column card grid
with 60-point timeline bars.

Backend:
- Add PrimaryPingLatencyMs + Timeline[60] to UserMonitorView
- ListRecentHistoryForMonitors: batch CTE + ROW_NUMBER() window query
- indexLatestByModel / indexAvailabilityByModel helpers

Frontend:
- 7 new components: ProviderIcon, MonitorMetricPair, MonitorAvailabilityRow,
  MonitorTimeline, MonitorHero, MonitorCard, MonitorCardGrid
- ChannelStatusView 381→~180 lines (delegated to subcomponents)
- AbortController reload concurrency protection
- HSL 0-120° availability color mapping
- Replace emoji with Icon component (bolt / globe)
- i18n: monitorCommon.* shared namespace, channelStatus.hero.*

Bump VERSION to 0.1.114.24
This commit is contained in:
erio
2026-04-20 23:38:59 +08:00
parent 20a4e41872
commit a1425b457d
19 changed files with 1134 additions and 278 deletions

View File

@@ -49,7 +49,12 @@ func (s *ChannelMonitorService) BatchMonitorStatusSummary(
}
// ListUserView 用户只读视图:列出所有 enabled 监控的概览。
// 使用批量聚合接口避免 N+11 次查 monitors1 次查 latest所有 monitor1 次查 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 列表。

View File

@@ -65,6 +65,9 @@ const (
// MonitorHistoryMaxLimit 历史查询最大返回条数handler 层共享)。
MonitorHistoryMaxLimit = 1000
// monitorTimelineMaxPoints 用户视图 timeline 每个监控最多返回的历史点数。
monitorTimelineMaxPoints = 60
// monitorEndpointResolveTimeout validateEndpoint 解析 hostname 的最长耗时。
monitorEndpointResolveTimeout = 5 * time.Second

View File

@@ -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 渠道监控管理服务。

View File

@@ -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 聚合)。