超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
请求量、5xx 比例、HTTP/数据库 P95 请求速率与延迟趋势 最慢接口排行 慢查询与数据库查询排行 Grafana 原始调用链入口
This commit is contained in:
@@ -46,6 +46,14 @@ Observability__MaximumSqlTextLength=2000
|
|||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||||
|
|
||||||
|
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||||
|
PerformanceReporting__Enabled=false
|
||||||
|
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||||
|
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||||
|
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||||
|
PerformanceReporting__CacheSeconds=30
|
||||||
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
|
||||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||||
Operations__BackupWarningHours=24
|
Operations__BackupWarningHours=24
|
||||||
|
|||||||
@@ -350,6 +350,27 @@ OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
|||||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||||
|
|
||||||
|
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||||
|
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||||
|
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||||
|
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||||
|
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||||
|
|
||||||
|
```text
|
||||||
|
PerformanceReporting__Enabled=true
|
||||||
|
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||||
|
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||||
|
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||||
|
PerformanceReporting__CacheSeconds=30
|
||||||
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
```
|
||||||
|
|
||||||
|
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query` 和 `/api/v1/query_range` 的
|
||||||
|
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
|
||||||
|
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
|
||||||
|
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||||
|
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||||
|
|
||||||
### 后台任务与 RabbitMQ
|
### 后台任务与 RabbitMQ
|
||||||
|
|
||||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||||
@@ -396,9 +417,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
|||||||
|
|
||||||
### 运维与审计控制台
|
### 运维与审计控制台
|
||||||
|
|
||||||
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
|
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||||
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
|
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||||
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
|
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||||
|
前端菜单隐藏。
|
||||||
|
|
||||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Operations;
|
using Jiaowu.Api.Infrastructure.Operations;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -18,8 +19,27 @@ public sealed class OperationsController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
OperationalHealthService healthService,
|
OperationalHealthService healthService,
|
||||||
DatabaseBackupService backupService,
|
DatabaseBackupService backupService,
|
||||||
|
PerformanceReportService performanceReportService,
|
||||||
OperationsOptions options) : ControllerBase
|
OperationsOptions options) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("performance")]
|
||||||
|
public async Task<ActionResult<PerformanceReport>> GetPerformance(
|
||||||
|
[FromQuery] string? range = "1h",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await performanceReportService.GetAsync(
|
||||||
|
range,
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return ValidationProblem(
|
||||||
|
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("summary")]
|
[HttpGet("summary")]
|
||||||
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class PerformanceReportService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IMemoryCache cache,
|
||||||
|
PerformanceReportingOptions options,
|
||||||
|
ObservabilityOptions observability,
|
||||||
|
ILogger<PerformanceReportService> logger)
|
||||||
|
{
|
||||||
|
public async Task<PerformanceReport> GetAsync(
|
||||||
|
string? range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rangeSpec = PerformanceRange.TryParse(range);
|
||||||
|
if (rangeSpec is null)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(range),
|
||||||
|
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||||
|
|
||||||
|
if (!options.Enabled ||
|
||||||
|
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
|
||||||
|
{
|
||||||
|
return PerformanceReport.NotConfigured(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"performance-report:{rangeSpec.Key}";
|
||||||
|
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
|
||||||
|
return cached!;
|
||||||
|
|
||||||
|
PerformanceReport report;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
report = await LoadAsync(rangeSpec, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Performance report source is unavailable for range {Range}.",
|
||||||
|
rangeSpec.Key);
|
||||||
|
report = PerformanceReport.Unavailable(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set(
|
||||||
|
cacheKey,
|
||||||
|
report,
|
||||||
|
TimeSpan.FromSeconds(options.CacheSeconds));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PerformanceReport> LoadAsync(
|
||||||
|
PerformanceRange range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var from = now - range.Duration;
|
||||||
|
var requestCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var requestBucketSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var errorCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName),
|
||||||
|
("http_response_status_code", "=~", "5.."));
|
||||||
|
var databaseCountSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var databaseBucketSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var slowDatabaseSelector = Selector(
|
||||||
|
options.SlowDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var failedDatabaseSelector = Selector(
|
||||||
|
options.FailedDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
|
||||||
|
var requestCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var serverErrorCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var databaseP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var slowCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var failedCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestTimelineTask = QueryRangeAsync(
|
||||||
|
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var latencyTimelineTask = QueryRangeAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var routeLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, http_route) (rate({requestBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}]))) * 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({requestCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeErrorTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({errorCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({databaseCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var querySlowTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
requestCountTask,
|
||||||
|
serverErrorCountTask,
|
||||||
|
requestP95Task,
|
||||||
|
databaseP95Task,
|
||||||
|
slowCountTask,
|
||||||
|
failedCountTask,
|
||||||
|
requestTimelineTask,
|
||||||
|
latencyTimelineTask,
|
||||||
|
routeLatencyTask,
|
||||||
|
routeCountTask,
|
||||||
|
routeErrorTask,
|
||||||
|
queryLatencyTask,
|
||||||
|
queryCountTask,
|
||||||
|
querySlowTask);
|
||||||
|
|
||||||
|
var requestCount = await requestCountTask;
|
||||||
|
var serverErrorCount = await serverErrorCountTask;
|
||||||
|
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
|
||||||
|
? serverErrorCount.Value / requestCount.Value * 100
|
||||||
|
: requestCount == 0
|
||||||
|
? 0
|
||||||
|
: null;
|
||||||
|
var timeline = MergeTimeline(
|
||||||
|
await requestTimelineTask,
|
||||||
|
await latencyTimelineTask);
|
||||||
|
var endpoints = MergeRanking(
|
||||||
|
await routeLatencyTask,
|
||||||
|
await routeCountTask,
|
||||||
|
await routeErrorTask,
|
||||||
|
"http_route");
|
||||||
|
var databaseQueries = MergeRanking(
|
||||||
|
await queryLatencyTask,
|
||||||
|
await queryCountTask,
|
||||||
|
await querySlowTask,
|
||||||
|
"db_query_name");
|
||||||
|
|
||||||
|
return new PerformanceReport(
|
||||||
|
"ready",
|
||||||
|
range.Key,
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
EmptyToNull(options.GrafanaBaseUrl),
|
||||||
|
null,
|
||||||
|
new PerformanceHeadline(
|
||||||
|
Round(requestCount),
|
||||||
|
Round(await requestP95Task),
|
||||||
|
Round(errorRate),
|
||||||
|
Round(await databaseP95Task),
|
||||||
|
Round(await slowCountTask),
|
||||||
|
Round(await failedCountTask)),
|
||||||
|
timeline,
|
||||||
|
endpoints,
|
||||||
|
databaseQueries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<double?> QueryScalarAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var vector = await QueryVectorAsync(
|
||||||
|
query,
|
||||||
|
time,
|
||||||
|
cancellationToken);
|
||||||
|
return vector.FirstOrDefault()?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query",
|
||||||
|
("query", query),
|
||||||
|
("time", ToUnixSeconds(time).ToString(
|
||||||
|
CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var data = document.RootElement.GetProperty("data");
|
||||||
|
var result = data.GetProperty("result");
|
||||||
|
var samples = new List<PrometheusSample>();
|
||||||
|
foreach (var item in result.EnumerateArray())
|
||||||
|
{
|
||||||
|
var labels = ReadLabels(item.GetProperty("metric"));
|
||||||
|
if (!TryReadValue(item.GetProperty("value"), out var value))
|
||||||
|
continue;
|
||||||
|
samples.Add(new PrometheusSample(labels, value));
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
|
||||||
|
string query,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
int stepSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query_range",
|
||||||
|
("query", query),
|
||||||
|
("start", ToUnixSeconds(from).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("end", ToUnixSeconds(to).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var result = document.RootElement
|
||||||
|
.GetProperty("data")
|
||||||
|
.GetProperty("result");
|
||||||
|
var first = result.EnumerateArray().FirstOrDefault();
|
||||||
|
if (first.ValueKind == JsonValueKind.Undefined ||
|
||||||
|
!first.TryGetProperty("values", out var values))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var points = new List<PerformanceSeriesPoint>();
|
||||||
|
foreach (var value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadValue(value, out var measurement)) continue;
|
||||||
|
var timestamp = value[0].GetDouble();
|
||||||
|
points.Add(new PerformanceSeriesPoint(
|
||||||
|
DateTimeOffset.FromUnixTimeMilliseconds(
|
||||||
|
checked((long)(timestamp * 1000))).UtcDateTime,
|
||||||
|
measurement));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonDocument> SendAsync(
|
||||||
|
Uri uri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.BearerToken))
|
||||||
|
{
|
||||||
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", options.BearerToken);
|
||||||
|
}
|
||||||
|
using var response = await httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(
|
||||||
|
cancellationToken);
|
||||||
|
var document = await JsonDocument.ParseAsync(
|
||||||
|
stream,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!document.RootElement.TryGetProperty("status", out var status) ||
|
||||||
|
status.GetString() != "success")
|
||||||
|
{
|
||||||
|
document.Dispose();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Prometheus 返回了非成功查询状态。");
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildUri(
|
||||||
|
string relativePath,
|
||||||
|
params (string Key, string Value)[] parameters)
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(
|
||||||
|
options.PrometheusBaseUrl.TrimEnd('/') + "/",
|
||||||
|
UriKind.Absolute);
|
||||||
|
var query = string.Join(
|
||||||
|
"&",
|
||||||
|
parameters.Select(parameter =>
|
||||||
|
$"{Uri.EscapeDataString(parameter.Key)}=" +
|
||||||
|
$"{Uri.EscapeDataString(parameter.Value)}"));
|
||||||
|
return new Uri(baseUri, $"{relativePath}?{query}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Selector(
|
||||||
|
string metric,
|
||||||
|
params (string Label, string Operator, string Value)[] filters)
|
||||||
|
{
|
||||||
|
var matchers = string.Join(
|
||||||
|
",",
|
||||||
|
filters.Select(filter =>
|
||||||
|
$"{filter.Label}{filter.Operator}\"" +
|
||||||
|
$"{EscapePrometheusValue(filter.Value)}\""));
|
||||||
|
return $"{metric}{{{matchers}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapePrometheusValue(string value) =>
|
||||||
|
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||||
|
.Replace("\r", "\\r", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ReadLabels(
|
||||||
|
JsonElement metric)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
foreach (var property in metric.EnumerateObject())
|
||||||
|
result[property.Name] = property.Value.GetString() ?? "";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadValue(
|
||||||
|
JsonElement value,
|
||||||
|
out double measurement)
|
||||||
|
{
|
||||||
|
measurement = 0;
|
||||||
|
if (value.ValueKind != JsonValueKind.Array ||
|
||||||
|
value.GetArrayLength() < 2)
|
||||||
|
return false;
|
||||||
|
var raw = value[1].GetString();
|
||||||
|
return double.TryParse(
|
||||||
|
raw,
|
||||||
|
NumberStyles.Float,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out measurement) &&
|
||||||
|
double.IsFinite(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> requestRate,
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> latency)
|
||||||
|
{
|
||||||
|
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
|
||||||
|
foreach (var point in requestRate)
|
||||||
|
{
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
Math.Round(point.Value, 3),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
foreach (var point in latency)
|
||||||
|
{
|
||||||
|
points.TryGetValue(point.Timestamp, out var existing);
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
existing?.RequestsPerSecond,
|
||||||
|
Math.Round(point.Value, 2));
|
||||||
|
}
|
||||||
|
return points.Values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
|
||||||
|
IReadOnlyList<PrometheusSample> latency,
|
||||||
|
IReadOnlyList<PrometheusSample> count,
|
||||||
|
IReadOnlyList<PrometheusSample> exceptional,
|
||||||
|
string label)
|
||||||
|
{
|
||||||
|
var names = latency
|
||||||
|
.Concat(count)
|
||||||
|
.Concat(exceptional)
|
||||||
|
.Select(item => item.Labels.GetValueOrDefault(label))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
var items = names.Select(name =>
|
||||||
|
{
|
||||||
|
var latencyValue = FindValue(latency, label, name);
|
||||||
|
var countValue = FindValue(count, label, name);
|
||||||
|
var exceptionalValue = FindValue(exceptional, label, name);
|
||||||
|
return new PerformanceRankingItem(
|
||||||
|
name!,
|
||||||
|
Round(latencyValue),
|
||||||
|
Round(countValue),
|
||||||
|
Round(exceptionalValue));
|
||||||
|
});
|
||||||
|
return items
|
||||||
|
.OrderByDescending(item => item.P95Milliseconds ?? -1)
|
||||||
|
.ThenByDescending(item => item.RequestCount ?? -1)
|
||||||
|
.Take(10)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? FindValue(
|
||||||
|
IReadOnlyList<PrometheusSample> samples,
|
||||||
|
string label,
|
||||||
|
string? name) =>
|
||||||
|
samples.FirstOrDefault(item =>
|
||||||
|
item.Labels.GetValueOrDefault(label) == name)?.Value;
|
||||||
|
|
||||||
|
private static double ToUnixSeconds(DateTime value) =>
|
||||||
|
new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||||
|
/ 1000d;
|
||||||
|
|
||||||
|
private static double? Round(double? value) =>
|
||||||
|
value.HasValue && double.IsFinite(value.Value)
|
||||||
|
? Math.Round(value.Value, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private sealed record PrometheusSample(
|
||||||
|
IReadOnlyDictionary<string, string> Labels,
|
||||||
|
double Value);
|
||||||
|
|
||||||
|
private sealed record PerformanceSeriesPoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PerformanceHeadline(
|
||||||
|
double? RequestCount,
|
||||||
|
double? RequestP95Milliseconds,
|
||||||
|
double? ServerErrorRatePercent,
|
||||||
|
double? DatabaseP95Milliseconds,
|
||||||
|
double? SlowDatabaseCommandCount,
|
||||||
|
double? FailedDatabaseCommandCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceTimelinePoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double? RequestsPerSecond,
|
||||||
|
double? RequestP95Milliseconds);
|
||||||
|
|
||||||
|
public sealed record PerformanceRankingItem(
|
||||||
|
string Name,
|
||||||
|
double? P95Milliseconds,
|
||||||
|
double? RequestCount,
|
||||||
|
double? ExceptionalCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceReport(
|
||||||
|
string Status,
|
||||||
|
string Range,
|
||||||
|
DateTime? From,
|
||||||
|
DateTime? To,
|
||||||
|
DateTime GeneratedAt,
|
||||||
|
string DataSource,
|
||||||
|
string? DashboardUrl,
|
||||||
|
string? Detail,
|
||||||
|
PerformanceHeadline? Headline,
|
||||||
|
IReadOnlyList<PerformanceTimelinePoint> Timeline,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> Endpoints,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
|
||||||
|
{
|
||||||
|
public static PerformanceReport NotConfigured(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"not_configured",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
|
||||||
|
"PerformanceReporting__PrometheusBaseUrl。");
|
||||||
|
|
||||||
|
public static PerformanceReport Unavailable(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"unavailable",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
|
||||||
|
|
||||||
|
private static PerformanceReport Empty(
|
||||||
|
string status,
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl,
|
||||||
|
string detail) =>
|
||||||
|
new(
|
||||||
|
status,
|
||||||
|
range,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
|
||||||
|
detail,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PerformanceRange(
|
||||||
|
string Key,
|
||||||
|
TimeSpan Duration,
|
||||||
|
string PrometheusRange,
|
||||||
|
string RateWindow,
|
||||||
|
int StepSeconds)
|
||||||
|
{
|
||||||
|
public static PerformanceRange? TryParse(string? value) =>
|
||||||
|
value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
|
||||||
|
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
|
||||||
|
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
|
||||||
|
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed partial class PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PerformanceReporting";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string PrometheusBaseUrl { get; set; } = "";
|
||||||
|
public string BearerToken { get; set; } = "";
|
||||||
|
public string GrafanaBaseUrl { get; set; } = "";
|
||||||
|
public int CacheSeconds { get; set; } = 30;
|
||||||
|
public int TimeoutSeconds { get; set; } = 10;
|
||||||
|
public string ServiceLabel { get; set; } = "service_name";
|
||||||
|
public string RequestDurationMetric { get; set; } =
|
||||||
|
"http_server_request_duration_seconds";
|
||||||
|
public string DatabaseDurationMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_duration_milliseconds";
|
||||||
|
public string SlowDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_slow_total";
|
||||||
|
public string FailedDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_failed_total";
|
||||||
|
|
||||||
|
public static bool IsMetricOrLabelName(string value) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) &&
|
||||||
|
PrometheusNamePattern().IsMatch(value);
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
|
||||||
|
private static partial Regex PrometheusNamePattern();
|
||||||
|
}
|
||||||
@@ -85,6 +85,9 @@ var operationsOptions = builder.Configuration
|
|||||||
var observabilityOptions = builder.Configuration
|
var observabilityOptions = builder.Configuration
|
||||||
.GetSection(ObservabilityOptions.SectionName)
|
.GetSection(ObservabilityOptions.SectionName)
|
||||||
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
|
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
|
||||||
|
var performanceReportingOptions = builder.Configuration
|
||||||
|
.GetSection(PerformanceReportingOptions.SectionName)
|
||||||
|
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||||
var rabbitMqOptions = builder.Configuration
|
var rabbitMqOptions = builder.Configuration
|
||||||
.GetSection(RabbitMqOptions.SectionName)
|
.GetSection(RabbitMqOptions.SectionName)
|
||||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||||
@@ -126,6 +129,28 @@ if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
|||||||
"Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。");
|
"Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
||||||
|
performanceReportingOptions.TimeoutSeconds is < 1 or > 60 ||
|
||||||
|
performanceReportingOptions.BearerToken.Length > 8000 ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.ServiceLabel) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.RequestDurationMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.DatabaseDurationMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.SlowDatabaseMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.FailedDatabaseMetric) ||
|
||||||
|
(performanceReportingOptions.Enabled &&
|
||||||
|
!IsHttpUrl(performanceReportingOptions.PrometheusBaseUrl)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(performanceReportingOptions.GrafanaBaseUrl) &&
|
||||||
|
!IsHttpUrl(performanceReportingOptions.GrafanaBaseUrl)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
||||||
|
}
|
||||||
|
|
||||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
||||||
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
||||||
@@ -219,8 +244,15 @@ builder.Services.AddSingleton(officialDocumentOptions);
|
|||||||
builder.Services.AddSingleton(backgroundJobOptions);
|
builder.Services.AddSingleton(backgroundJobOptions);
|
||||||
builder.Services.AddSingleton(operationsOptions);
|
builder.Services.AddSingleton(operationsOptions);
|
||||||
builder.Services.AddSingleton(observabilityOptions);
|
builder.Services.AddSingleton(observabilityOptions);
|
||||||
|
builder.Services.AddSingleton(performanceReportingOptions);
|
||||||
builder.Services.AddSingleton(rabbitMqOptions);
|
builder.Services.AddSingleton(rabbitMqOptions);
|
||||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||||
|
{
|
||||||
|
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
||||||
|
});
|
||||||
builder.Services.Configure<OfficialDocumentOptions>(
|
builder.Services.Configure<OfficialDocumentOptions>(
|
||||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||||
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||||
@@ -668,4 +700,8 @@ static async Task<IResult> CheckMessagingHealthAsync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool IsHttpUrl(string value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
||||||
|
uri.Scheme is "http" or "https";
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -26,6 +26,19 @@
|
|||||||
"IncludeSqlText": false,
|
"IncludeSqlText": false,
|
||||||
"MaximumSqlTextLength": 2000
|
"MaximumSqlTextLength": 2000
|
||||||
},
|
},
|
||||||
|
"PerformanceReporting": {
|
||||||
|
"Enabled": false,
|
||||||
|
"PrometheusBaseUrl": "",
|
||||||
|
"BearerToken": "",
|
||||||
|
"GrafanaBaseUrl": "",
|
||||||
|
"CacheSeconds": 30,
|
||||||
|
"TimeoutSeconds": 10,
|
||||||
|
"ServiceLabel": "service_name",
|
||||||
|
"RequestDurationMetric": "http_server_request_duration_seconds",
|
||||||
|
"DatabaseDurationMetric": "jiaowu_db_command_duration_milliseconds",
|
||||||
|
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
||||||
|
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
||||||
|
},
|
||||||
"Operations": {
|
"Operations": {
|
||||||
"BackupDirectory": "data/backups",
|
"BackupDirectory": "data/backups",
|
||||||
"BackupWarningHours": 24,
|
"BackupWarningHours": 24,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
|
|||||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Operations;
|
using Jiaowu.Api.Infrastructure.Operations;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
@@ -201,6 +202,7 @@ public sealed class OperationsControllerTests
|
|||||||
|
|
||||||
var services = new ServiceCollection()
|
var services = new ServiceCollection()
|
||||||
.AddLogging()
|
.AddLogging()
|
||||||
|
.AddMemoryCache()
|
||||||
.BuildServiceProvider();
|
.BuildServiceProvider();
|
||||||
var logger = services.GetRequiredService<
|
var logger = services.GetRequiredService<
|
||||||
ILogger<DatabaseBackupService>>();
|
ILogger<DatabaseBackupService>>();
|
||||||
@@ -224,10 +226,19 @@ public sealed class OperationsControllerTests
|
|||||||
configuration,
|
configuration,
|
||||||
environment,
|
environment,
|
||||||
logger);
|
logger);
|
||||||
|
var performance = new PerformanceReportService(
|
||||||
|
new HttpClient(),
|
||||||
|
services.GetRequiredService<
|
||||||
|
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
services.GetRequiredService<
|
||||||
|
ILogger<PerformanceReportService>>());
|
||||||
var controller = new OperationsController(
|
var controller = new OperationsController(
|
||||||
db,
|
db,
|
||||||
health,
|
health,
|
||||||
backups,
|
backups,
|
||||||
|
performance,
|
||||||
operationsOptions);
|
operationsOptions);
|
||||||
return new OperationsFixture(services, db, controller, backups);
|
return new OperationsFixture(services, db, controller, backups);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class PerformanceReportServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Configured_source_returns_cached_native_report()
|
||||||
|
{
|
||||||
|
var handler = new PrometheusHandler();
|
||||||
|
using var httpClient = new HttpClient(handler);
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
httpClient,
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
PrometheusBaseUrl = "https://prometheus.test/",
|
||||||
|
BearerToken = "read-only-token",
|
||||||
|
GrafanaBaseUrl = "https://grafana.test/",
|
||||||
|
CacheSeconds = 30
|
||||||
|
},
|
||||||
|
new ObservabilityOptions { ServiceName = "jiaowu-api" },
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var first = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
var second = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("ready", first.Status);
|
||||||
|
Assert.Equal("prometheus", first.DataSource);
|
||||||
|
Assert.Equal("https://grafana.test/", first.DashboardUrl);
|
||||||
|
Assert.NotNull(first.Headline);
|
||||||
|
Assert.Equal(5, first.Headline.RequestCount);
|
||||||
|
Assert.Equal(2, first.Timeline.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
"/api/timetables/classes/{classId}",
|
||||||
|
Assert.Single(first.Endpoints).Name);
|
||||||
|
Assert.Equal(
|
||||||
|
"Timetable.LoadScheduleEntries",
|
||||||
|
Assert.Single(first.DatabaseQueries).Name);
|
||||||
|
Assert.Same(first, second);
|
||||||
|
Assert.Equal(14, handler.RequestCount);
|
||||||
|
Assert.True(handler.AllRequestsAuthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_source_returns_directed_empty_state()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new RejectingHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var report = await service.GetAsync("24h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("not_configured", report.Status);
|
||||||
|
Assert.Contains("Prometheus", report.Detail);
|
||||||
|
Assert.Empty(report.Timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsupported_range_is_rejected_before_querying_source()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new RejectingHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
service.GetAsync("30d", CancellationToken.None));
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
service.GetAsync(null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_timeout_returns_unavailable_report()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new TimeoutHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
PrometheusBaseUrl = "https://prometheus.test/"
|
||||||
|
},
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var report = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("unavailable", report.Status);
|
||||||
|
Assert.Contains("暂时不可用", report.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PrometheusHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private int requestCount;
|
||||||
|
private int authenticatedCount;
|
||||||
|
|
||||||
|
public int RequestCount => requestCount;
|
||||||
|
public bool AllRequestsAuthenticated =>
|
||||||
|
authenticatedCount == requestCount;
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref requestCount);
|
||||||
|
if (request.Headers.Authorization?.Scheme == "Bearer" &&
|
||||||
|
request.Headers.Authorization.Parameter == "read-only-token")
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref authenticatedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRange = request.RequestUri!.AbsolutePath.EndsWith(
|
||||||
|
"/query_range",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
var json = isRange
|
||||||
|
? $$"""
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "matrix",
|
||||||
|
"result": [{
|
||||||
|
"metric": {},
|
||||||
|
"values": [
|
||||||
|
[{{now - 60}}, "2"],
|
||||||
|
[{{now}}, "3"]
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
: $$"""
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "vector",
|
||||||
|
"result": [{
|
||||||
|
"metric": {
|
||||||
|
"http_route": "/api/timetables/classes/{classId}",
|
||||||
|
"db_query_name": "Timetable.LoadScheduleEntries"
|
||||||
|
},
|
||||||
|
"value": [{{now}}, "5"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
json,
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RejectingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"未配置时不应访问外部数据源。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TimeoutHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new TaskCanceledException("Prometheus query timed out.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -42,6 +42,7 @@ declare module 'vue' {
|
|||||||
ElResult: typeof import('element-plus/es')['ElResult']
|
ElResult: typeof import('element-plus/es')['ElResult']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
@@ -52,6 +53,7 @@ declare module 'vue' {
|
|||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
||||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,762 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { Connection, RefreshRight, TopRight } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { LineChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
])
|
||||||
|
|
||||||
|
type ReportStatus = 'ready' | 'not_configured' | 'unavailable'
|
||||||
|
type ReportRange = '15m' | '1h' | '24h' | '7d'
|
||||||
|
|
||||||
|
interface PerformanceHeadline {
|
||||||
|
requestCount?: number
|
||||||
|
requestP95Milliseconds?: number
|
||||||
|
serverErrorRatePercent?: number
|
||||||
|
databaseP95Milliseconds?: number
|
||||||
|
slowDatabaseCommandCount?: number
|
||||||
|
failedDatabaseCommandCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelinePoint {
|
||||||
|
timestamp: string
|
||||||
|
requestsPerSecond?: number
|
||||||
|
requestP95Milliseconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankingItem {
|
||||||
|
name: string
|
||||||
|
p95Milliseconds?: number
|
||||||
|
requestCount?: number
|
||||||
|
exceptionalCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerformanceReport {
|
||||||
|
status: ReportStatus
|
||||||
|
range: ReportRange
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
generatedAt: string
|
||||||
|
dataSource: string
|
||||||
|
dashboardUrl?: string
|
||||||
|
detail?: string
|
||||||
|
headline?: PerformanceHeadline
|
||||||
|
timeline: TimelinePoint[]
|
||||||
|
endpoints: RankingItem[]
|
||||||
|
databaseQueries: RankingItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeOptions: { value: ReportRange; label: string }[] = [
|
||||||
|
{ value: '15m', label: '15 分钟' },
|
||||||
|
{ value: '1h', label: '1 小时' },
|
||||||
|
{ value: '24h', label: '24 小时' },
|
||||||
|
{ value: '7d', label: '7 天' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const range = ref<ReportRange>('1h')
|
||||||
|
const loading = ref(true)
|
||||||
|
const report = ref<PerformanceReport>()
|
||||||
|
const chartElement = ref<HTMLElement>()
|
||||||
|
let chart: echarts.ECharts | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
|
const hasSamples = computed(() =>
|
||||||
|
Boolean(report.value?.headline) &&
|
||||||
|
(report.value?.timeline.length ?? 0) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const operatingNote = computed(() => {
|
||||||
|
const headline = report.value?.headline
|
||||||
|
if (!headline) return '等待指标样本'
|
||||||
|
if ((headline.failedDatabaseCommandCount ?? 0) > 0)
|
||||||
|
return '存在失败的数据库命令'
|
||||||
|
if ((headline.serverErrorRatePercent ?? 0) >= 1)
|
||||||
|
return '服务端错误率需要关注'
|
||||||
|
if ((headline.slowDatabaseCommandCount ?? 0) > 0)
|
||||||
|
return '存在超过阈值的慢查询'
|
||||||
|
return '当前采样窗口内未见明显异常'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatNumber(value?: number, digits = 0) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—'
|
||||||
|
return value.toLocaleString('zh-CN', {
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
month: range.value === '7d' ? '2-digit' : undefined,
|
||||||
|
day: range.value === '7d' ? '2-digit' : undefined,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricWidth(value?: number, rows: RankingItem[] = []) {
|
||||||
|
if (value == null || value <= 0) return '0%'
|
||||||
|
const maximum = Math.max(
|
||||||
|
...rows.map((row) => row.p95Milliseconds ?? 0),
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return `${Math.max(4, value / maximum * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get<PerformanceReport>(
|
||||||
|
'/operations/performance',
|
||||||
|
{ params: { range: range.value } },
|
||||||
|
)
|
||||||
|
report.value = data
|
||||||
|
await nextTick()
|
||||||
|
renderChart()
|
||||||
|
} catch (error) {
|
||||||
|
report.value = {
|
||||||
|
status: 'unavailable',
|
||||||
|
range: range.value,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataSource: 'prometheus',
|
||||||
|
detail: apiErrorMessage(error),
|
||||||
|
timeline: [],
|
||||||
|
endpoints: [],
|
||||||
|
databaseQueries: [],
|
||||||
|
}
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
disposeChart()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
disposeChart()
|
||||||
|
if (!chartElement.value || !report.value?.timeline.length) return
|
||||||
|
chart = echarts.init(chartElement.value)
|
||||||
|
const times = report.value.timeline.map((point) =>
|
||||||
|
formatAxisTime(point.timestamp),
|
||||||
|
)
|
||||||
|
chart.setOption({
|
||||||
|
animationDuration: 420,
|
||||||
|
color: ['#1f7468', '#b47722'],
|
||||||
|
grid: { left: 50, right: 54, top: 54, bottom: 38 },
|
||||||
|
legend: {
|
||||||
|
top: 4,
|
||||||
|
left: 0,
|
||||||
|
itemWidth: 18,
|
||||||
|
itemHeight: 3,
|
||||||
|
textStyle: {
|
||||||
|
color: '#53636d',
|
||||||
|
fontFamily: '"Cascadia Mono", Consolas, monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(30, 41, 50, 0.94)',
|
||||||
|
borderWidth: 0,
|
||||||
|
textStyle: { color: '#fff', fontSize: 12 },
|
||||||
|
valueFormatter: (value: unknown) =>
|
||||||
|
typeof value === 'number' ? value.toFixed(2) : '—',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: times,
|
||||||
|
axisLine: { lineStyle: { color: '#cbd4d7' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#79878f', fontSize: 10, hideOverlap: true },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '请求 / 秒',
|
||||||
|
nameTextStyle: { color: '#63727b', fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: '#e8edef', type: 'dashed' } },
|
||||||
|
axisLabel: { color: '#79878f', fontSize: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: 'P95 / ms',
|
||||||
|
nameTextStyle: { color: '#8b6b36', fontSize: 10 },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { color: '#8b6b36', fontSize: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '请求速率',
|
||||||
|
type: 'line',
|
||||||
|
smooth: 0.22,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: { color: 'rgba(31, 116, 104, 0.10)' },
|
||||||
|
data: report.value.timeline.map(
|
||||||
|
(point) => point.requestsPerSecond ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '接口 P95',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
smooth: 0.22,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 1.5 },
|
||||||
|
data: report.value.timeline.map(
|
||||||
|
(point) => point.requestP95Milliseconds ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
resizeObserver = new ResizeObserver(() => chart?.resize())
|
||||||
|
resizeObserver.observe(chartElement.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeChart() {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = undefined
|
||||||
|
chart?.dispose()
|
||||||
|
chart = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(range, loadReport)
|
||||||
|
onMounted(loadReport)
|
||||||
|
onUnmounted(disposeChart)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="performance-panel" aria-labelledby="performance-title">
|
||||||
|
<header class="performance-heading">
|
||||||
|
<div>
|
||||||
|
<span>PERFORMANCE RAIL / {{ report?.dataSource?.toUpperCase() || 'PROMETHEUS' }}</span>
|
||||||
|
<h3 id="performance-title">系统性能</h3>
|
||||||
|
<p>从接口进入数据库,定位响应时间消耗在哪里。</p>
|
||||||
|
</div>
|
||||||
|
<div class="performance-actions">
|
||||||
|
<div class="range-switch" aria-label="性能报表时间范围">
|
||||||
|
<button
|
||||||
|
v-for="item in rangeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: range === item.value }"
|
||||||
|
:aria-pressed="range === item.value"
|
||||||
|
@click="range = item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
:icon="RefreshRight"
|
||||||
|
:loading="loading"
|
||||||
|
aria-label="刷新性能报表"
|
||||||
|
@click="loadReport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !report" class="performance-loading">
|
||||||
|
<el-skeleton :rows="5" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="report?.status !== 'ready'"
|
||||||
|
:class="`is-${report?.status || 'unavailable'}`"
|
||||||
|
class="source-state"
|
||||||
|
>
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{{ report?.status === 'not_configured' ? '等待连接性能数据源' : '性能数据暂不可用' }}
|
||||||
|
</strong>
|
||||||
|
<p>{{ report?.detail }}</p>
|
||||||
|
<small>
|
||||||
|
业务接口继续正常运行;报表不会回退读取教务业务数据库。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="report?.dashboardUrl"
|
||||||
|
:href="report.dashboardUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
打开监控平台
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="performance-strip">
|
||||||
|
<div class="strip-lead">
|
||||||
|
<span>采样结论</span>
|
||||||
|
<strong>{{ operatingNote }}</strong>
|
||||||
|
<small>
|
||||||
|
{{ formatTime(report.from) }} — {{ formatTime(report.to) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>请求总量</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.requestCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>接口 P95</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.requestP95Milliseconds, 1) }}<small>ms</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>5xx 比例</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.serverErrorRatePercent, 2) }}<small>%</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据库 P95</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.databaseP95Milliseconds, 1) }}<small>ms</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>慢查询</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.slowDatabaseCommandCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>查询失败</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.failedDatabaseCommandCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSamples" class="performance-rail">
|
||||||
|
<div ref="chartElement" class="rail-chart" aria-label="请求速率与接口 P95 趋势图" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="samples-empty">
|
||||||
|
数据源已连接,但该时间范围内尚未收到请求指标。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rankings">
|
||||||
|
<article class="ranking-board">
|
||||||
|
<div class="ranking-heading">
|
||||||
|
<div>
|
||||||
|
<span>HTTP ROUTES</span>
|
||||||
|
<h4>接口耗时排行</h4>
|
||||||
|
</div>
|
||||||
|
<small>P95 / 请求量 / 5xx</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.endpoints.length" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in report.endpoints"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-row"
|
||||||
|
>
|
||||||
|
<b>{{ String(index + 1).padStart(2, '0') }}</b>
|
||||||
|
<div>
|
||||||
|
<strong :title="item.name">{{ item.name }}</strong>
|
||||||
|
<span>
|
||||||
|
<i
|
||||||
|
:style="{ width: metricWidth(item.p95Milliseconds, report.endpoints) }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
|
||||||
|
<dt>{{ formatNumber(item.requestCount) }} 次 · {{ formatNumber(item.exceptionalCount) }} 错误</dt>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="ranking-empty">该范围内没有接口指标。</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ranking-board database-board">
|
||||||
|
<div class="ranking-heading">
|
||||||
|
<div>
|
||||||
|
<span>DATABASE QUERIES</span>
|
||||||
|
<h4>数据库查询排行</h4>
|
||||||
|
</div>
|
||||||
|
<small>P95 / 调用量 / 慢查询</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.databaseQueries.length" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in report.databaseQueries"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-row"
|
||||||
|
>
|
||||||
|
<b>{{ String(index + 1).padStart(2, '0') }}</b>
|
||||||
|
<div>
|
||||||
|
<strong :title="item.name">{{ item.name }}</strong>
|
||||||
|
<span>
|
||||||
|
<i
|
||||||
|
:style="{ width: metricWidth(item.p95Milliseconds, report.databaseQueries) }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
|
||||||
|
<dt>{{ formatNumber(item.requestCount) }} 次 · {{ formatNumber(item.exceptionalCount) }} 慢</dt>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="ranking-empty">该范围内没有数据库指标。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="performance-foot">
|
||||||
|
<span>生成于 {{ formatTime(report.generatedAt) }} · 页面数据采用短时缓存</span>
|
||||||
|
<a
|
||||||
|
v-if="report.dashboardUrl"
|
||||||
|
:href="report.dashboardUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
查看原始调用链
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.performance-panel {
|
||||||
|
--ink: #1e2932;
|
||||||
|
--muted: #66747e;
|
||||||
|
--line: #d8dee1;
|
||||||
|
--paper: #f6f8f8;
|
||||||
|
--panel: #fff;
|
||||||
|
--signal: #1f7468;
|
||||||
|
--warning: #b47722;
|
||||||
|
--danger: #b1463e;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading {
|
||||||
|
align-items: flex-end;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(31, 116, 104, 0.06), transparent 38%),
|
||||||
|
var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading span,
|
||||||
|
.ranking-heading span,
|
||||||
|
.strip-lead > span {
|
||||||
|
color: var(--signal);
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading h3 {
|
||||||
|
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 21px;
|
||||||
|
margin: 5px 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch {
|
||||||
|
background: #edf1f1;
|
||||||
|
display: flex;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #68767e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
transition: background .16s ease, color .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button.active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button:focus-visible {
|
||||||
|
outline: 2px solid var(--signal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-loading {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #f8faf9;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
margin: 22px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state > .el-icon {
|
||||||
|
background: #e7efed;
|
||||||
|
color: var(--signal);
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state p,
|
||||||
|
.source-state small {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state p { font-size: 12px; }
|
||||||
|
.source-state small { font-size: 10px; }
|
||||||
|
.source-state.is-unavailable > .el-icon { background: #f6e9e8; color: var(--danger); }
|
||||||
|
|
||||||
|
.source-state a,
|
||||||
|
.performance-foot a {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--signal);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-lead {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-lead > span { color: #84c7bc; }
|
||||||
|
.strip-lead strong { display: block; font-size: 13px; margin: 9px 0 13px; }
|
||||||
|
.strip-lead small { color: #aebbc2; font-family: "Cascadia Mono", Consolas, monospace; font-size: 9px; }
|
||||||
|
|
||||||
|
.performance-strip > dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip dl > div {
|
||||||
|
border-right: 1px solid #e5eaec;
|
||||||
|
padding: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip dl > div:last-child { border-right: 0; }
|
||||||
|
.performance-strip dt { color: var(--muted); font-size: 10px; margin-bottom: 9px; }
|
||||||
|
.performance-strip dd { font-family: "Cascadia Mono", Consolas, monospace; font-size: 18px; margin: 0; }
|
||||||
|
.performance-strip dd small { color: var(--muted); font-size: 9px; margin-left: 3px; }
|
||||||
|
|
||||||
|
.performance-rail {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 16px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-chart { height: 260px; width: 100%; }
|
||||||
|
|
||||||
|
.samples-empty,
|
||||||
|
.ranking-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 44px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.samples-empty { border-bottom: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.rankings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-board {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-board:last-child { border-right: 0; }
|
||||||
|
|
||||||
|
.ranking-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-heading h4 {
|
||||||
|
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-heading > small {
|
||||||
|
color: #89959b;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list { display: grid; gap: 1px; }
|
||||||
|
|
||||||
|
.ranking-row {
|
||||||
|
align-items: center;
|
||||||
|
background: #f8fafa;
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr) 116px;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > b {
|
||||||
|
color: #9aa6ab;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > div { min-width: 0; }
|
||||||
|
.ranking-row strong {
|
||||||
|
display: block;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > div > span {
|
||||||
|
background: #dfe7e7;
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row i {
|
||||||
|
background: var(--signal);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-board .ranking-row i { background: var(--warning); }
|
||||||
|
|
||||||
|
.ranking-row dl {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row dd {
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row dt { color: var(--muted); font-size: 9px; }
|
||||||
|
|
||||||
|
.performance-foot {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--paper);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.range-switch button { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.performance-strip { grid-template-columns: 190px minmax(0, 1fr); }
|
||||||
|
.performance-strip > dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.performance-strip dl > div:nth-child(3) { border-right: 0; }
|
||||||
|
.performance-strip dl > div:nth-child(-n+3) { border-bottom: 1px solid #e5eaec; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.performance-heading { align-items: flex-start; gap: 16px; }
|
||||||
|
.performance-actions { align-items: flex-end; flex-direction: column-reverse; }
|
||||||
|
.performance-strip { display: block; }
|
||||||
|
.rankings { grid-template-columns: 1fr; }
|
||||||
|
.ranking-board { border-bottom: 1px solid var(--line); border-right: 0; }
|
||||||
|
.ranking-board:last-child { border-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.performance-heading { display: block; padding: 17px 14px; }
|
||||||
|
.performance-actions { align-items: stretch; flex-direction: row; margin-top: 14px; }
|
||||||
|
.range-switch { flex: 1; overflow-x: auto; }
|
||||||
|
.range-switch button { flex: 1 0 auto; padding-inline: 9px; }
|
||||||
|
.source-state { grid-template-columns: auto 1fr; margin: 12px; padding: 16px; }
|
||||||
|
.source-state a { grid-column: 2; }
|
||||||
|
.performance-strip > dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.performance-strip dl > div,
|
||||||
|
.performance-strip dl > div:nth-child(3) { border-bottom: 1px solid #e5eaec; border-right: 1px solid #e5eaec; }
|
||||||
|
.performance-strip dl > div:nth-child(even) { border-right: 0; }
|
||||||
|
.performance-strip dl > div:nth-last-child(-n+2) { border-bottom: 0; }
|
||||||
|
.performance-rail { padding-inline: 8px; }
|
||||||
|
.rail-chart { height: 230px; }
|
||||||
|
.ranking-board { padding-inline: 12px; }
|
||||||
|
.ranking-heading > small { display: none; }
|
||||||
|
.ranking-row { grid-template-columns: 22px minmax(0, 1fr) 96px; padding-inline: 7px; }
|
||||||
|
.performance-foot { align-items: flex-start; gap: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
||||||
|
import PerformanceReportPanel from '../components/PerformanceReportPanel.vue'
|
||||||
|
|
||||||
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
||||||
|
|
||||||
@@ -406,6 +407,8 @@ onMounted(refreshAll)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<PerformanceReportPanel />
|
||||||
|
|
||||||
<section class="console-split">
|
<section class="console-split">
|
||||||
<article class="alerts-panel">
|
<article class="alerts-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
Reference in New Issue
Block a user