refactor: 移除资源用量的可选 Prometheus 接入与平台监控页
- 资源用量弹窗:删除 applyPrometheus/promQueryValue 与峰值/限流字段 (LOKI_USAGE_PROMETHEUS_URL 从未内置,代码恒走降级),统一提示 「未接入 Prometheus:暂无峰值/限流数据」;扩容建议改基于 metrics-server 实时值 - 移除「平台监控」菜单页与 /api/v1/system/metrics-summary; 原始指标保留在标准 /api/v1/metrics(Prometheus 文本格式)可自行抓取 - 手册/落地页/CHANGELOG 同步(去除备份恢复与监控页宣传,指标端点表述中性化)
This commit is contained in:
1 parent
f87fb4b2b3
commit
c1f822fb10
17 files changed
+9
-465
No files matched your search
+2
-1
@@ -7,11 +7,12 @@
|
||||
|
||||
### 移除
|
||||
- 备份恢复功能(含 API / CLI / 手册):日志数据本体的保留与安全由对象存储与 Loki retention 承担,平台侧备份价值有限
|
||||
- 平台监控页与 `/api/v1/system/metrics-summary`:原始指标仍经标准 `/api/v1/metrics`(Prometheus 文本格式)暴露,可自行抓取
|
||||
- 资源用量弹窗的可选 Prometheus 接入(峰值/限流查询):统一以「未接入 Prometheus:暂无峰值/限流数据」提示,扩容建议基于 metrics-server 实时值
|
||||
|
||||
### 新增
|
||||
- 落地页(homepage/,`/homepage` 访问):产品介绍、特性、快速开始
|
||||
- 用户手册文档站(docsify,`/docs` 访问):安装、部署向导、采集、查询、分析、告警、授权、备份、FAQ 十篇
|
||||
- 平台自监控(`/monitor`,管理员):请求量/5xx/DB 大小/Goroutines 统计卡,Top 路由、状态码、告警评估、Loki 查询与落盘明细(后端 `/api/v1/system/metrics-summary` 快照端点)
|
||||
|
||||
## [2.0.0] - 2026-10-29
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"lokistack/pkg/metrics"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -45,7 +44,3 @@ func (ctrl *SystemController) Health(c *gin.Context) {
|
||||
utils.Success(c, services.SelfCheck())
|
||||
}
|
||||
|
||||
// MetricsSummary 平台自监控快照(仅 admin;原始 Prometheus 文本在 /api/v1/metrics)
|
||||
func (ctrl *SystemController) MetricsSummary(c *gin.Context) {
|
||||
utils.Success(c, metrics.Snapshot())
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
// PathCount 单个路由模板的累计请求数
|
||||
type PathCount struct {
|
||||
Path string `json:"path"`
|
||||
Count float64 `json:"count"`
|
||||
}
|
||||
|
||||
// Summary 面板自监控快照:从 registry 聚合出的精简结构(/api/v1/system/metrics-summary)
|
||||
type Summary struct {
|
||||
HTTP struct {
|
||||
Total float64 `json:"total"`
|
||||
ByStatus map[string]float64 `json:"byStatus"`
|
||||
InFlight float64 `json:"inFlight"`
|
||||
TopPaths []PathCount `json:"topPaths"`
|
||||
} `json:"http"`
|
||||
DB struct {
|
||||
SizeBytes float64 `json:"sizeBytes"`
|
||||
Saves float64 `json:"saves"`
|
||||
SaveFailures float64 `json:"saveFailures"`
|
||||
Records map[string]float64 `json:"records"`
|
||||
} `json:"db"`
|
||||
Alerts struct {
|
||||
Evaluations map[string]float64 `json:"evaluations"`
|
||||
} `json:"alerts"`
|
||||
Loki struct {
|
||||
Queries map[string]float64 `json:"queries"`
|
||||
} `json:"loki"`
|
||||
Runtime struct {
|
||||
Goroutines float64 `json:"goroutines"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
Version string `json:"version"`
|
||||
} `json:"runtime"`
|
||||
}
|
||||
|
||||
// Snapshot 聚合当前指标为面板结构(聚合失败返回零值,监控页降级展示)
|
||||
func Snapshot() Summary {
|
||||
s := Summary{}
|
||||
s.HTTP.ByStatus = map[string]float64{}
|
||||
s.DB.Records = map[string]float64{}
|
||||
s.Alerts.Evaluations = map[string]float64{}
|
||||
s.Loki.Queries = map[string]float64{}
|
||||
|
||||
families, err := registry.Gather()
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
byPath := map[string]float64{}
|
||||
for _, mf := range families {
|
||||
for _, m := range mf.GetMetric() {
|
||||
labels := labelMap(m)
|
||||
switch mf.GetName() {
|
||||
case "lokistack_http_requests_total":
|
||||
v := m.GetCounter().GetValue()
|
||||
s.HTTP.Total += v
|
||||
s.HTTP.ByStatus[labels["status"]] += v
|
||||
byPath[labels["path"]] += v
|
||||
case "lokistack_http_in_flight_requests":
|
||||
s.HTTP.InFlight = m.GetGauge().GetValue()
|
||||
case "lokistack_db_size_bytes":
|
||||
s.DB.SizeBytes = m.GetGauge().GetValue()
|
||||
case "lokistack_db_save_total":
|
||||
v := m.GetCounter().GetValue()
|
||||
s.DB.Saves += v
|
||||
if labels["result"] != "success" {
|
||||
s.DB.SaveFailures += v
|
||||
}
|
||||
case "lokistack_db_records":
|
||||
s.DB.Records[labels["collection"]] = m.GetGauge().GetValue()
|
||||
case "lokistack_alert_evaluations_total":
|
||||
s.Alerts.Evaluations[labels["result"]] += m.GetCounter().GetValue()
|
||||
case "lokistack_loki_queries_total":
|
||||
s.Loki.Queries[labels["result"]] += m.GetCounter().GetValue()
|
||||
case "go_goroutines":
|
||||
s.Runtime.Goroutines = m.GetGauge().GetValue()
|
||||
case "lokistack_build_info":
|
||||
s.Runtime.GoVersion = labels["go_version"]
|
||||
s.Runtime.Version = labels["version"]
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := make([]PathCount, 0, len(byPath))
|
||||
for p, c := range byPath {
|
||||
paths = append(paths, PathCount{Path: p, Count: c})
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool { return paths[i].Count > paths[j].Count })
|
||||
if len(paths) > 8 {
|
||||
paths = paths[:8]
|
||||
}
|
||||
s.HTTP.TopPaths = paths
|
||||
return s
|
||||
}
|
||||
|
||||
func labelMap(m *dto.Metric) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, lp := range m.GetLabel() {
|
||||
out[lp.GetName()] = lp.GetValue()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -148,7 +148,6 @@ func SetupRouter() *gin.Engine {
|
||||
system.Use(middleware.AdminAuth())
|
||||
{
|
||||
system.GET("/self-check", systemCtrl.Health)
|
||||
system.GET("/metrics-summary", middleware.AdminAuth(), systemCtrl.MetricsSummary)
|
||||
}
|
||||
|
||||
// 审计日志:仅管理员可查(含他人操作记录)
|
||||
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,7 +16,7 @@ import (
|
||||
// ResourceUsage 是页面上「资源」弹框的数据。
|
||||
//
|
||||
// 三种部署模式都能采集:
|
||||
// - k8s-yaml : StatefulSet resources + metrics-server(实时用量)+ 可选 Prometheus(峰值/限流)
|
||||
// - k8s-yaml : StatefulSet resources + metrics-server(实时用量)
|
||||
// - host-docker : docker stats / docker inspect(容器限额未设则按宿主机内存做参考)
|
||||
// - host-binary : ps 采集进程 CPU%/RSS(无 cgroup 限额)
|
||||
//
|
||||
@@ -45,11 +42,6 @@ type ResourceUsage struct {
|
||||
// 限额是否真的配置了(docker 未加 --memory 时为 false,页面不画百分比)
|
||||
LimitsConfigured bool `json:"limitsConfigured"`
|
||||
|
||||
// 可选(需 Prometheus)
|
||||
CPUPeakMilli *int64 `json:"cpuPeakMilli,omitempty"`
|
||||
MemPeakBytes *int64 `json:"memPeakBytes,omitempty"`
|
||||
ThrottlePercent *float64 `json:"throttlePercent,omitempty"`
|
||||
|
||||
// 采集受限说明(如缺 metrics.k8s.io 权限、未装 metrics-server、docker 无历史数据)
|
||||
MetricsNote string `json:"metricsNote,omitempty"`
|
||||
|
||||
@@ -148,7 +140,6 @@ func (s *DeploymentService) k8sUsage(ctx context.Context, d *models.LokiDeployme
|
||||
usage.MetricsNote = "无法解析 metrics-server 返回"
|
||||
}
|
||||
|
||||
s.applyPrometheus(usage)
|
||||
usage.buildHints()
|
||||
return usage, nil
|
||||
}
|
||||
@@ -167,29 +158,6 @@ func explainMetricsError(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// applyPrometheus 补 24h 峰值与 CPU 限流率(未配置 Prometheus 地址则跳过)。
|
||||
func (s *DeploymentService) applyPrometheus(u *ResourceUsage) {
|
||||
base := strings.TrimSpace(os.Getenv("LOKI_USAGE_PROMETHEUS_URL"))
|
||||
if base == "" || u.Pod == "" {
|
||||
if u.MetricsNote == "" {
|
||||
u.MetricsNote = "未配置 Prometheus,暂无 24h 峰值与限流率"
|
||||
}
|
||||
return
|
||||
}
|
||||
sel := fmt.Sprintf(`pod=%q,namespace=%q,container="loki"`, u.Pod, u.Namespace)
|
||||
if v, ok := promQueryValue(base, fmt.Sprintf(`max_over_time(sum(rate(container_cpu_usage_seconds_total{%s}[5m]))[24h:1m])`, sel)); ok {
|
||||
milli := int64(v * 1000)
|
||||
u.CPUPeakMilli = &milli
|
||||
}
|
||||
if v, ok := promQueryValue(base, fmt.Sprintf(`max_over_time(sum(container_memory_working_set_bytes{%s})[24h:1m])`, sel)); ok {
|
||||
b := int64(v)
|
||||
u.MemPeakBytes = &b
|
||||
}
|
||||
if v, ok := promQueryValue(base, fmt.Sprintf(`100 * sum(rate(container_cpu_cfs_throttled_periods_total{pod=%q,namespace=%q}[15m])) / sum(rate(container_cpu_cfs_periods_total{pod=%q,namespace=%q}[15m]))`, u.Pod, u.Namespace, u.Pod, u.Namespace)); ok {
|
||||
u.ThrottlePercent = &v
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- host-docker ----------
|
||||
|
||||
func (s *DeploymentService) dockerUsage(ctx context.Context, d *models.LokiDeployment) (*ResourceUsage, error) {
|
||||
@@ -245,7 +213,7 @@ func (s *DeploymentService) dockerUsage(ctx context.Context, d *models.LokiDeplo
|
||||
usage.MetricsNote = "docker stats 读取失败:" + err.Error()
|
||||
}
|
||||
if usage.MetricsNote == "" {
|
||||
usage.MetricsNote = "Docker 部署无历史峰值;如需 24h 峰值请接入 cAdvisor/Prometheus"
|
||||
usage.MetricsNote = "未接入 Prometheus:暂无峰值/限流数据"
|
||||
}
|
||||
|
||||
usage.buildHints()
|
||||
@@ -287,14 +255,9 @@ func (s *DeploymentService) processUsage(ctx context.Context, d *models.LokiDepl
|
||||
// ---------- 共用:扩容建议 ----------
|
||||
|
||||
func (u *ResourceUsage) buildHints() {
|
||||
// 峰值需 Prometheus,平台不接入:扩容建议基于 metrics-server 实时值
|
||||
cpuRef := u.CPUCurrentMilli
|
||||
if u.CPUPeakMilli != nil && *u.CPUPeakMilli > cpuRef {
|
||||
cpuRef = *u.CPUPeakMilli
|
||||
}
|
||||
memRef := u.MemCurrentBytes
|
||||
if u.MemPeakBytes != nil && *u.MemPeakBytes > memRef {
|
||||
memRef = *u.MemPeakBytes
|
||||
}
|
||||
|
||||
var notes []string
|
||||
if u.CPULimitMilli > 0 {
|
||||
@@ -319,9 +282,6 @@ func (u *ResourceUsage) buildHints() {
|
||||
notes = append(notes, "内存偏高")
|
||||
}
|
||||
}
|
||||
if u.ThrottlePercent != nil && *u.ThrottlePercent >= 10 {
|
||||
notes = append(notes, fmt.Sprintf("CPU 限流 %.0f%%", *u.ThrottlePercent))
|
||||
}
|
||||
if !u.Ready {
|
||||
notes = append(notes, "实例未就绪")
|
||||
}
|
||||
@@ -337,40 +297,6 @@ func (u *ResourceUsage) buildHints() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Prometheus ----------
|
||||
|
||||
// promQueryValue 查 Prometheus 瞬时值;未配置或失败返回 ok=false(页面降级)。
|
||||
func promQueryValue(base, expr string) (float64, bool) {
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
u := strings.TrimSuffix(base, "/") + "/api/v1/query?query=" + url.QueryEscape(expr)
|
||||
resp, err := client.Get(u)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return 0, false
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Value [2]interface{} `json:"value"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&payload) != nil || len(payload.Data.Result) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
if len(payload.Data.Result[0].Value) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
raw, ok := payload.Data.Result[0].Value[1].(string)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return parseFloatSafe(raw), true
|
||||
}
|
||||
|
||||
// ---------- 单位解析 ----------
|
||||
|
||||
// parseQuantityMilli 解析 CPU 用量/限额为毫核。
|
||||
|
||||
@@ -8,7 +8,7 @@ LokiStack 把 Grafana Loki 日志聚合的部署、采集、查询、分析与
|
||||
- **采集日志**:Promtail / Fluent Bit 采集策略可视化配置,命名空间多选、app 标签来源四级回退、敏感路径拦截。
|
||||
- **查询与分析**:LogQL 高亮补全、快捷查询、Live Tail;分析页自动聚合日志量、错误与 Ingress 访问指标。
|
||||
- **告警**:内置九条应用日志规则,绑定默认通道后填入飞书 Webhook 即可接收;支持冷却与风暴抑制。
|
||||
- **运维底座**:凭证加密存储、操作审计、Prometheus 自监控指标。
|
||||
- **运维底座**:凭证加密存储、操作审计、标准 Prometheus 指标端点(/api/v1/metrics)。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import zhAnalysis from './locales/zh/analysis'
|
||||
import enAnalysis from './locales/en/analysis'
|
||||
import zhLogs from './locales/zh/logs'
|
||||
import enLogs from './locales/en/logs'
|
||||
import zhMonitor from './locales/zh/monitor'
|
||||
import enMonitor from './locales/en/monitor'
|
||||
import zhSettings from './locales/zh/settings'
|
||||
import enSettings from './locales/en/settings'
|
||||
import zhAudit from './locales/zh/audit'
|
||||
@@ -33,8 +31,6 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
||||
['en', 'analysis', enAnalysis],
|
||||
['zh', 'logs', zhLogs],
|
||||
['en', 'logs', enLogs],
|
||||
['zh', 'monitor', zhMonitor],
|
||||
['en', 'monitor', enMonitor],
|
||||
['zh', 'settings', zhSettings],
|
||||
['en', 'settings', enSettings],
|
||||
['zh', 'audit', zhAudit],
|
||||
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
logs: 'Log Query',
|
||||
alerts: 'Alerts',
|
||||
analysis: 'Log Analysis',
|
||||
monitor: 'Monitor',
|
||||
settings: 'General',
|
||||
ldap: 'LDAP',
|
||||
registry: 'Image Registry',
|
||||
|
||||
@@ -123,7 +123,6 @@ export default {
|
||||
ready: 'Ready',
|
||||
notReady: 'Not ready',
|
||||
restarts: 'Restarts: {{count}}',
|
||||
throttle: 'CPU throttled {{percent}}%',
|
||||
promMissing: 'Prometheus not configured: no peak/throttle data',
|
||||
cpu: 'CPU',
|
||||
memory: 'Memory',
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Platform self-monitoring — registered via i18n/domains.ts
|
||||
export default {
|
||||
title: 'Platform Monitor',
|
||||
subtitle: 'Runtime metrics of the platform itself: requests, DB saves, alert engine and runtime (raw Prometheus metrics at /api/v1/metrics)',
|
||||
adminOnly: {
|
||||
title: 'Admin only',
|
||||
desc: 'Runtime metrics are available to administrators.',
|
||||
},
|
||||
refresh: 'Refresh',
|
||||
loadFailed: 'Failed to load metrics',
|
||||
empty: 'No data yet',
|
||||
stats: {
|
||||
requests: 'HTTP requests',
|
||||
errors5xx: '5xx errors',
|
||||
dbSize: 'Database size',
|
||||
goroutines: 'Goroutines',
|
||||
},
|
||||
topPaths: {
|
||||
title: 'Top routes by requests',
|
||||
subtitle: 'Cumulative requests grouped by route template',
|
||||
path: 'Route',
|
||||
count: 'Requests',
|
||||
},
|
||||
breakdown: {
|
||||
title: 'Breakdown',
|
||||
subtitle: 'Status codes / alert evaluations / Loki queries / DB saves',
|
||||
byStatus: 'HTTP status codes',
|
||||
alerts: 'Alert rule evaluations',
|
||||
loki: 'Queries against Loki',
|
||||
db: 'DB saves',
|
||||
dbSaves: 'Saves',
|
||||
dbFailures: 'Save failures',
|
||||
},
|
||||
result: {
|
||||
success: 'Success',
|
||||
error: 'Failed',
|
||||
fired: 'Fired',
|
||||
skipped: 'No match',
|
||||
cooldown: 'Cooldown suppressed',
|
||||
},
|
||||
buildInfo: 'Version {{version}} · Go {{go}}',
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
logs: '日志查询',
|
||||
alerts: '日志告警',
|
||||
analysis: '日志分析',
|
||||
monitor: '平台监控',
|
||||
settings: '通用配置',
|
||||
ldap: 'LDAP 接入',
|
||||
registry: '默认镜像源',
|
||||
|
||||
@@ -122,7 +122,6 @@ export default {
|
||||
ready: '就绪',
|
||||
notReady: '未就绪',
|
||||
restarts: '重启 {{count}} 次',
|
||||
throttle: 'CPU 限流 {{percent}}%',
|
||||
promMissing: '未接入 Prometheus:暂无峰值/限流数据',
|
||||
cpu: 'CPU',
|
||||
memory: '内存',
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 平台自监控页 — 经 i18n/domains.ts 注册
|
||||
export default {
|
||||
title: '平台监控',
|
||||
subtitle: '平台自身运行指标:请求、数据落盘、告警引擎与运行时(原始 Prometheus 指标见 /api/v1/metrics)',
|
||||
adminOnly: {
|
||||
title: '仅管理员可访问',
|
||||
desc: '平台运行指标面向管理员开放。',
|
||||
},
|
||||
refresh: '刷新',
|
||||
loadFailed: '加载监控数据失败',
|
||||
empty: '暂无数据',
|
||||
stats: {
|
||||
requests: 'HTTP 请求总数',
|
||||
errors5xx: '5xx 错误数',
|
||||
dbSize: '数据库大小',
|
||||
goroutines: 'Goroutines',
|
||||
},
|
||||
topPaths: {
|
||||
title: '请求量 Top 路由',
|
||||
subtitle: '按路由模板聚合的累计请求数',
|
||||
path: '路由',
|
||||
count: '请求数',
|
||||
},
|
||||
breakdown: {
|
||||
title: '分布明细',
|
||||
subtitle: '状态码 / 告警评估 / Loki 查询 / 数据落盘',
|
||||
byStatus: 'HTTP 状态码',
|
||||
alerts: '告警规则评估',
|
||||
loki: '对 Loki 的查询',
|
||||
db: '数据落盘',
|
||||
dbSaves: '落盘次数',
|
||||
dbFailures: '落盘失败',
|
||||
},
|
||||
result: {
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
fired: '已触发',
|
||||
skipped: '未命中',
|
||||
cooldown: '冷却抑制',
|
||||
},
|
||||
buildInfo: '版本 {{version}} · Go {{go}}',
|
||||
}
|
||||
@@ -22,9 +22,6 @@ interface Usage {
|
||||
memCurrentBytes: number
|
||||
memRequestBytes: number
|
||||
memLimitBytes: number
|
||||
cpuPeakMilli?: number
|
||||
memPeakBytes?: number
|
||||
throttlePercent?: number
|
||||
cpuSuggest?: string
|
||||
memSuggest?: string
|
||||
summary: string
|
||||
@@ -34,12 +31,11 @@ const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed(2)
|
||||
const cores = (milli: number) => (milli / 1000).toFixed(2)
|
||||
|
||||
/** 用条形容使用率;超 90% 红、70% 黄、其余绿 */
|
||||
function UsageBar({ label, current, limit, format, peak }: {
|
||||
function UsageBar({ label, current, limit, format }: {
|
||||
label: string
|
||||
current: number
|
||||
limit: number
|
||||
format: (v: number) => string
|
||||
peak?: number
|
||||
}) {
|
||||
const ratio = limit > 0 ? Math.min(1, current / limit) : 0
|
||||
const tone = ratio >= 0.9 ? 'bg-destructive' : ratio >= 0.7 ? 'bg-warning' : 'bg-success'
|
||||
@@ -49,7 +45,6 @@ function UsageBar({ label, current, limit, format, peak }: {
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{format(current)} / {format(limit)}({(ratio * 100).toFixed(0)}%)
|
||||
{peak !== undefined && peak > 0 && <span className="ml-2">峰值 {format(peak)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
@@ -120,12 +115,7 @@ export const DeploymentUsageDialog = memo(function DeploymentUsageDialog({ deplo
|
||||
</span>
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">{usage.source}</span>
|
||||
<span className="text-muted-foreground">{t('usage.restarts', { count: usage.restarts })}</span>
|
||||
{usage.throttlePercent !== undefined && (
|
||||
<span className={usage.throttlePercent >= 10 ? 'rounded bg-warning/15 px-2 py-0.5 text-warning' : 'text-muted-foreground'}>
|
||||
{t('usage.throttle', { percent: usage.throttlePercent.toFixed(1) })}
|
||||
</span>
|
||||
)}
|
||||
{usage.throttlePercent === undefined && <span className="text-muted-foreground">{t('usage.promMissing')}</span>}
|
||||
<span className="text-muted-foreground">{t('usage.promMissing')}</span>
|
||||
</div>
|
||||
|
||||
{usage.limitsConfigured ? (
|
||||
@@ -134,14 +124,12 @@ export const DeploymentUsageDialog = memo(function DeploymentUsageDialog({ deplo
|
||||
label={t('usage.cpu')}
|
||||
current={usage.cpuCurrentMilli}
|
||||
limit={usage.cpuLimitMilli}
|
||||
peak={usage.cpuPeakMilli}
|
||||
format={v => `${cores(v)} 核`}
|
||||
/>
|
||||
<UsageBar
|
||||
label={t('usage.memory')}
|
||||
current={usage.memCurrentBytes}
|
||||
limit={usage.memLimitBytes}
|
||||
peak={usage.memPeakBytes}
|
||||
format={v => `${gb(v)} GiB`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import '@/i18n/domains'
|
||||
import { Activity, Database, Gauge, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { PageContainer } from '@/components/PageContainer'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { StatCard } from '@/components/StatCard'
|
||||
import { http } from '@/api/http'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
interface PathCount { path: string; count: number }
|
||||
interface Summary {
|
||||
http: { total: number; byStatus: Record<string, number>; inFlight: number; topPaths: PathCount[] }
|
||||
db: { sizeBytes: number; saves: number; saveFailures: number; records: Record<string, number> }
|
||||
alerts: { evaluations: Record<string, number> }
|
||||
loki: { queries: Record<string, number> }
|
||||
runtime: { goroutines: number; goVersion: string; version: string }
|
||||
}
|
||||
|
||||
const fmtBytes = (n: number) => {
|
||||
if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(2)} GB`
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`
|
||||
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`
|
||||
return `${n} B`
|
||||
}
|
||||
|
||||
/** 平台自监控(admin):聚合 /metrics 为可读快照 — 请求/DB/告警引擎/运行时 */
|
||||
const MonitorPage = memo(function MonitorPage() {
|
||||
const { t } = useTranslation('monitor')
|
||||
const isAdmin = getUser()?.role === 'admin'
|
||||
const [data, setData] = useState<Summary | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const versionRef = useRef(0)
|
||||
|
||||
const load = useCallback(() => {
|
||||
const v = ++versionRef.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
http.get<Summary>('/system/metrics-summary')
|
||||
.then(result => { if (v === versionRef.current) setData(result) })
|
||||
.catch(err => { if (v === versionRef.current) setError(err instanceof Error ? err.message : t('loadFailed')) })
|
||||
.finally(() => { if (v === versionRef.current) setLoading(false) })
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) load()
|
||||
}, [isAdmin, load])
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<PageContainer title={t('title')}>
|
||||
<EmptyState icon={Activity} title={t('adminOnly.title')} description={t('adminOnly.desc')} />
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const statusEntries = data ? Object.entries(data.http.byStatus).sort(([a], [b]) => a.localeCompare(b)) : []
|
||||
const err5xx = statusEntries.filter(([code]) => code.startsWith('5')).reduce((acc, [, v]) => acc + v, 0)
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t('title')}
|
||||
description={t('subtitle')}
|
||||
action={
|
||||
<Button variant="outline" onClick={load} disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <RefreshCw className="mr-1 size-4" />}
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : loading && !data ? (
|
||||
<LoadingSpinner className="h-64" />
|
||||
) : !data ? null : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label={t('stats.requests')} value={data.http.total.toLocaleString()} icon={Activity} iconColor="text-info" iconBg="bg-info/10" />
|
||||
<StatCard label={t('stats.errors5xx')} value={err5xx.toLocaleString()} icon={Gauge} iconColor={err5xx > 0 ? 'text-destructive' : 'text-muted-foreground'} iconBg={err5xx > 0 ? 'bg-destructive/10' : 'bg-muted'} />
|
||||
<StatCard label={t('stats.dbSize')} value={fmtBytes(data.db.sizeBytes)} icon={Database} iconColor="text-primary" iconBg="bg-primary/10" />
|
||||
<StatCard label={t('stats.goroutines')} value={data.runtime.goroutines} icon={Gauge} iconColor="text-warning" iconBg="bg-warning/10" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('topPaths.title')}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t('topPaths.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{data.http.topPaths.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('empty')}</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr><th className="border-b px-3 py-2 font-medium">{t('topPaths.path')}</th><th className="border-b px-3 py-2 font-medium text-right">{t('topPaths.count')}</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.http.topPaths.map(item => (
|
||||
<tr key={item.path} className="hover:bg-muted/40">
|
||||
<td className="px-3 py-2 font-mono">{item.path}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{item.count.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('breakdown.title')}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t('breakdown.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0 text-xs">
|
||||
<div>
|
||||
<p className="mb-1 font-medium">{t('breakdown.byStatus')}</p>
|
||||
{statusEntries.length === 0 ? <p className="text-muted-foreground">{t('empty')}</p> : statusEntries.map(([code, count]) => (
|
||||
<div key={code} className="flex items-center justify-between py-0.5">
|
||||
<span className={`font-mono ${code.startsWith('5') ? 'text-destructive' : code.startsWith('4') ? 'text-warning' : 'text-success'}`}>{code}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-medium">{t('breakdown.alerts')}</p>
|
||||
{Object.entries(data.alerts.evaluations).map(([result, count]) => (
|
||||
<div key={result} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-muted-foreground">{t(`result.${result}`, result)}</span>
|
||||
<span className="tabular-nums">{count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-medium">{t('breakdown.loki')}</p>
|
||||
{Object.entries(data.loki.queries).map(([result, count]) => (
|
||||
<div key={result} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-muted-foreground">{t(`result.${result}`, result)}</span>
|
||||
<span className="tabular-nums">{count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-medium">{t('breakdown.db')}</p>
|
||||
<div className="flex items-center justify-between py-0.5"><span className="text-muted-foreground">{t('breakdown.dbSaves')}</span><span className="tabular-nums">{data.db.saves.toLocaleString()}</span></div>
|
||||
<div className="flex items-center justify-between py-0.5"><span className={data.db.saveFailures > 0 ? 'text-destructive' : 'text-muted-foreground'}>{t('breakdown.dbFailures')}</span><span className="tabular-nums">{data.db.saveFailures.toLocaleString()}</span></div>
|
||||
</div>
|
||||
<p className="border-t pt-3 text-muted-foreground">{t('buildInfo', { version: data.runtime.version || '-', go: data.runtime.goVersion || '-' })}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
)
|
||||
})
|
||||
|
||||
export default MonitorPage
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ScrollText,
|
||||
BellRing,
|
||||
BarChart3,
|
||||
Activity,
|
||||
History,
|
||||
Boxes,
|
||||
ShieldCheck,
|
||||
@@ -39,7 +38,6 @@ const LogsPage = lazy(() => import('@/pages/logs'));
|
||||
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
||||
const UsersPage = lazy(() => import('@/pages/users'));
|
||||
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
||||
const MonitorPage = lazy(() => import('@/pages/monitor'));
|
||||
const AuditPage = lazy(() => import('@/pages/audit'));
|
||||
|
||||
export const routes: AppRoute[] = [
|
||||
@@ -72,7 +70,6 @@ export const routes: AppRoute[] = [
|
||||
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
|
||||
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
|
||||
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } },
|
||||
{ path: '/monitor', component: MonitorPage, auth: true, meta: { title: 'menu.monitor', icon: Activity, order: 6.5, adminOnly: true } },
|
||||
|
||||
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
||||
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
<div class="feature">
|
||||
<div class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
<h3>工程级可靠性</h3>
|
||||
<p>Credential 加密存储、操作审计、备份恢复、Prometheus 自监控指标、CI 门禁与测试覆盖,按生产标准构建。</p>
|
||||
<p>凭证加密存储、操作审计、标准 Prometheus 指标端点、CI 门禁与测试覆盖,按生产标准构建。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user