超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
请求量、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_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__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 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
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
@@ -396,9 +417,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
|
||||
### 运维与审计控制台
|
||||
|
||||
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
|
||||
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
|
||||
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
|
||||
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||
前端菜单隐藏。
|
||||
|
||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -18,8 +19,27 @@ public sealed class OperationsController(
|
||||
AppDbContext db,
|
||||
OperationalHealthService healthService,
|
||||
DatabaseBackupService backupService,
|
||||
PerformanceReportService performanceReportService,
|
||||
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")]
|
||||
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
||||
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
|
||||
.GetSection(ObservabilityOptions.SectionName)
|
||||
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
|
||||
var performanceReportingOptions = builder.Configuration
|
||||
.GetSection(PerformanceReportingOptions.SectionName)
|
||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
@@ -126,6 +129,28 @@ if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||
"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"];
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
||||
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
||||
@@ -219,8 +244,15 @@ builder.Services.AddSingleton(officialDocumentOptions);
|
||||
builder.Services.AddSingleton(backgroundJobOptions);
|
||||
builder.Services.AddSingleton(operationsOptions);
|
||||
builder.Services.AddSingleton(observabilityOptions);
|
||||
builder.Services.AddSingleton(performanceReportingOptions);
|
||||
builder.Services.AddSingleton(rabbitMqOptions);
|
||||
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.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
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;
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
"IncludeSqlText": false,
|
||||
"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": {
|
||||
"BackupDirectory": "data/backups",
|
||||
"BackupWarningHours": 24,
|
||||
|
||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -201,6 +202,7 @@ public sealed class OperationsControllerTests
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddMemoryCache()
|
||||
.BuildServiceProvider();
|
||||
var logger = services.GetRequiredService<
|
||||
ILogger<DatabaseBackupService>>();
|
||||
@@ -224,10 +226,19 @@ public sealed class OperationsControllerTests
|
||||
configuration,
|
||||
environment,
|
||||
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(
|
||||
db,
|
||||
health,
|
||||
backups,
|
||||
performance,
|
||||
operationsOptions);
|
||||
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']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
@@ -52,6 +53,7 @@ declare module 'vue' {
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
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 http, { apiErrorMessage } from '../api/http'
|
||||
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
||||
import PerformanceReportPanel from '../components/PerformanceReportPanel.vue'
|
||||
|
||||
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
||||
|
||||
@@ -406,6 +407,8 @@ onMounted(refreshAll)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PerformanceReportPanel />
|
||||
|
||||
<section class="console-split">
|
||||
<article class="alerts-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||
Reference in New Issue
Block a user