超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:

请求量、5xx 比例、HTTP/数据库 P95
请求速率与延迟趋势
最慢接口排行
慢查询与数据库查询排行
Grafana 原始调用链入口
This commit is contained in:
2026-07-31 09:34:31 +08:00 Unverified
parent ca7148ab61
commit 551f1143b7
12 changed files with 1655 additions and 3 deletions
+8
View File
@@ -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
+25 -3
View File
@@ -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();
}
+36
View File
@@ -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;
+13
View File
@@ -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.");
}
}
+2
View File
@@ -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>
+3
View File
@@ -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">