From 551f1143b76d19e517d5bc368a779bda8e72fb2e Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 31 Jul 2026 09:34:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8F=AF=E5=9C=A8=E2=80=9C=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E9=99=90=20=E2=86=92=20=E8=BF=90=E7=BB=B4?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E8=AE=A1=20=E2=86=92=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E2=80=9D=E4=B8=AD=E7=9B=B4=E6=8E=A5=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=EF=BC=9A=20=E8=AF=B7=E6=B1=82=E9=87=8F=E3=80=815xx=20?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E3=80=81HTTP/=E6=95=B0=E6=8D=AE=E5=BA=93=20P?= =?UTF-8?q?95=20=E8=AF=B7=E6=B1=82=E9=80=9F=E7=8E=87=E4=B8=8E=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E8=B6=8B=E5=8A=BF=20=E6=9C=80=E6=85=A2=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=8E=92=E8=A1=8C=20=E6=85=A2=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93=E6=9F=A5=E8=AF=A2=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=20Grafana=20=E5=8E=9F=E5=A7=8B=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E9=93=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + README.md | 28 +- .../Controllers/OperationsController.cs | 20 + .../Observability/PerformanceReportService.cs | 556 +++++++++++++ .../PerformanceReportingOptions.cs | 31 + src/Jiaowu.Api/Program.cs | 36 + src/Jiaowu.Api/appsettings.json | 13 + .../OperationsControllerTests.cs | 11 + .../PerformanceReportServiceTests.cs | 188 +++++ web/src/components.d.ts | 2 + web/src/components/PerformanceReportPanel.vue | 762 ++++++++++++++++++ web/src/views/OperationsConsoleView.vue | 3 + 12 files changed, 1655 insertions(+), 3 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportService.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportingOptions.cs create mode 100644 tests/Jiaowu.Api.Tests/PerformanceReportServiceTests.cs create mode 100644 web/src/components/PerformanceReportPanel.vue diff --git a/.env.example b/.env.example index c5071c7..6916de5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index c2ff716..dfbd917 100644 --- a/README.md +++ b/README.md @@ -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` 客户端),并配置独立的 diff --git a/src/Jiaowu.Api/Controllers/OperationsController.cs b/src/Jiaowu.Api/Controllers/OperationsController.cs index 6cfb587..0093827 100644 --- a/src/Jiaowu.Api/Controllers/OperationsController.cs +++ b/src/Jiaowu.Api/Controllers/OperationsController.cs @@ -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> 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> GetSummary( CancellationToken cancellationToken) diff --git a/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportService.cs b/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportService.cs new file mode 100644 index 0000000..321224c --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportService.cs @@ -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 logger) +{ + public async Task 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(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 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 QueryScalarAsync( + string query, + DateTime time, + CancellationToken cancellationToken) + { + var vector = await QueryVectorAsync( + query, + time, + cancellationToken); + return vector.FirstOrDefault()?.Value; + } + + private async Task> 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(); + 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> 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(); + 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 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 ReadLabels( + JsonElement metric) + { + var result = new Dictionary( + 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 MergeTimeline( + IReadOnlyList requestRate, + IReadOnlyList latency) + { + var points = new SortedDictionary(); + 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 MergeRanking( + IReadOnlyList latency, + IReadOnlyList count, + IReadOnlyList 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 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 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 Timeline, + IReadOnlyList Endpoints, + IReadOnlyList 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 + }; +} diff --git a/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportingOptions.cs b/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportingOptions.cs new file mode 100644 index 0000000..df9d34b --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Observability/PerformanceReportingOptions.cs @@ -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(); +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index d7858c8..5973004 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -85,6 +85,9 @@ var operationsOptions = builder.Configuration var observabilityOptions = builder.Configuration .GetSection(ObservabilityOptions.SectionName) .Get() ?? new ObservabilityOptions(); +var performanceReportingOptions = builder.Configuration + .GetSection(PerformanceReportingOptions.SectionName) + .Get() ?? new PerformanceReportingOptions(); var rabbitMqOptions = builder.Configuration .GetSection(RabbitMqOptions.SectionName) .Get() ?? 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(); +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient((services, client) => +{ + var reporting = services.GetRequiredService(); + client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds); +}); builder.Services.Configure( builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Services.AddDbContextPool((services, options) => @@ -668,4 +700,8 @@ static async Task CheckMessagingHealthAsync( } } +static bool IsHttpUrl(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && + uri.Scheme is "http" or "https"; + public partial class Program; diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index d25ac0d..46e1124 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -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, diff --git a/tests/Jiaowu.Api.Tests/OperationsControllerTests.cs b/tests/Jiaowu.Api.Tests/OperationsControllerTests.cs index d7dabfd..ce3d82e 100644 --- a/tests/Jiaowu.Api.Tests/OperationsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/OperationsControllerTests.cs @@ -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>(); @@ -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>()); var controller = new OperationsController( db, health, backups, + performance, operationsOptions); return new OperationsFixture(services, db, controller, backups); } diff --git a/tests/Jiaowu.Api.Tests/PerformanceReportServiceTests.cs b/tests/Jiaowu.Api.Tests/PerformanceReportServiceTests.cs new file mode 100644 index 0000000..caef7d3 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/PerformanceReportServiceTests.cs @@ -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.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.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.Instance); + + await Assert.ThrowsAsync(() => + service.GetAsync("30d", CancellationToken.None)); + await Assert.ThrowsAsync(() => + 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.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 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 SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + throw new InvalidOperationException( + "未配置时不应访问外部数据源。"); + } + + private sealed class TimeoutHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + throw new TaskCanceledException("Prometheus query timed out."); + } +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 23cc1a9..7c23b38 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -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'] diff --git a/web/src/components/PerformanceReportPanel.vue b/web/src/components/PerformanceReportPanel.vue new file mode 100644 index 0000000..39e9c85 --- /dev/null +++ b/web/src/components/PerformanceReportPanel.vue @@ -0,0 +1,762 @@ + + + + + diff --git a/web/src/views/OperationsConsoleView.vue b/web/src/views/OperationsConsoleView.vue index 4b90c3b..690ed39 100644 --- a/web/src/views/OperationsConsoleView.vue +++ b/web/src/views/OperationsConsoleView.vue @@ -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) + +