261 lines
14 KiB
C#
261 lines
14 KiB
C#
using Fleet.Core;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using MoShiro.Backend.Analysis;
|
|
using MoShiro.Backend.Application;
|
|
using MoShiro.Protocol;
|
|
|
|
namespace MoShiro.Backend.Api;
|
|
|
|
/// <summary>展示数据的查询端点:参数解析、校验与缓存头,业务都在 Application.Query。</summary>
|
|
public static class QueryEndpoints
|
|
{
|
|
private const string Tag = "public";
|
|
private const string DefaultOverviewPreset = "quic,tcp,scores";
|
|
private const string DefaultSeriesPreset = "quic,tcp";
|
|
|
|
public static void Map(RouteGroupBuilder group)
|
|
{
|
|
group.MapGet("/overview", OverviewAsync)
|
|
.Api("overview_get", "Ranking of all lines over a time range", Tag)
|
|
.Returns<OverviewDto>().ProducesProblems(403, 422, 429, 503);
|
|
group.MapGet("/ipv6", Ipv6Async)
|
|
.Api("ipv6_get", "IPv6 configuration and collector diagnostics without addresses", Tag)
|
|
.Returns<PageDto<AgentIpv6Dto>>().ProducesProblems(403);
|
|
group.MapGet("/lines/{line_id}", LineAsync)
|
|
.Api("lines_get", "One line", Tag)
|
|
.Returns<LineDto>().ProducesProblems(403, 404);
|
|
group.MapGet("/lines/{line_id}/series", SeriesAsync)
|
|
.Api("lines_series", "Time series of one line (columnar)", Tag)
|
|
.Returns<SeriesDto>().ProducesProblems(403, 404, 422, 429, 503);
|
|
group.MapGet("/compare", CompareAsync)
|
|
.Api("lines_compare", "One metric across several lines, aligned on one time axis", Tag)
|
|
.Returns<CompareDto>().ProducesProblems(403, 404, 422, 429, 503);
|
|
group.MapGet("/lines/{line_id}/distribution", DistributionAsync)
|
|
.Api("lines_distribution", "Histogram and CDF of a histogram metric", Tag)
|
|
.Returns<DistributionDto>().ProducesProblems(403, 404, 422, 429, 503);
|
|
group.MapGet("/lines/{line_id}/heatmap", HeatmapAsync)
|
|
.Api("lines_heatmap", "Time by latency heat map", Tag)
|
|
.Returns<HeatmapDto>().ProducesProblems(403, 404, 422, 429, 503);
|
|
group.MapGet("/tests/{test_id}", TestAsync)
|
|
.Api("tests_get", "A test with its result", Tag)
|
|
.Returns<TestDto>().ProducesProblems(403, 404);
|
|
}
|
|
|
|
private static async Task<IResult> Ipv6Async(HttpContext http, AgentService agents, CancellationToken ct)
|
|
{
|
|
IReadOnlyList<AgentView> views = await agents.ListAsync(ct);
|
|
return TypedResults.Json(new PageDto<AgentIpv6Dto>([.. views.Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && (v.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
|
.Select(v => new AgentIpv6Dto(v.Agent.Id.Value, v.Agent.Name, v.Online, Mappers.Ipv6Of(v)))], null), ApiJsonContext.Default.PageDtoAgentIpv6Dto);
|
|
}
|
|
|
|
private static Task<IResult> OverviewAsync(
|
|
HttpContext http, LineService lineService, QueryService query, BackendOptions options, TimeProvider time,
|
|
string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order, string? ip_version) =>
|
|
QueryHelpers.GuardedAsync(http, async ct =>
|
|
{
|
|
string ipVersion = ip_version ?? IpVersions.V4;
|
|
if (!IpVersions.IsValid(ipVersion))
|
|
{
|
|
return Problems.From(http, ValidationError.Single("ip_version", "must be ipv4 or ipv6"));
|
|
}
|
|
|
|
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery);
|
|
if (!selection.TryGet(out MetricSelection? sel, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
IReadOnlyList<LineView> visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct);
|
|
Result<OverviewResult> result = await query.OverviewAsync([.. visible.Where(l => l.IpVersion == ipVersion)], new RangeRequest(range, from, to, null, null), sel, ct);
|
|
if (!result.TryGet(out OverviewResult? overview, out error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
IEnumerable<OverviewLine> lines = Sort(overview.Lines, sort, order, sel);
|
|
IResult? cached = QueryHelpers.ApplyCaching(http, overview.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), overview.Plan.Name + string.Join(',', sel.AllIds));
|
|
if (cached is not null)
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
var dto = new OverviewDto(
|
|
range ?? "custom", Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs),
|
|
overview.Plan.Name, overview.Plan.Degraded, [.. lines.Select(Mappers.ToDto)]);
|
|
return TypedResults.Json(dto, ApiJsonContext.Default.OverviewDto);
|
|
});
|
|
|
|
private static async Task<IResult> LineAsync(HttpContext http, LineService lines, TestService tests, QueryService query, string line_id, CancellationToken ct)
|
|
{
|
|
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
|
if (!found.TryGet(out LineView? line, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
AgentView view = line.Agent;
|
|
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct);
|
|
TestRecord? last = recent.Count > 0 ? recent[0] : null;
|
|
return TypedResults.Json(
|
|
new LineDto(
|
|
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
|
|
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
|
|
ApiJsonContext.Default.LineDto);
|
|
}
|
|
|
|
private static Task<IResult> SeriesAsync(
|
|
HttpContext http, LineService lines, QueryService query, BackendOptions options, TimeProvider time,
|
|
string line_id, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step, string? tz, string? preset, string? metrics, string? anchor) =>
|
|
QueryHelpers.GuardedAsync(http, async ct =>
|
|
{
|
|
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
|
if (!found.TryGet(out LineView? view, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultSeriesPreset : null), options.MaxMetricsPerQuery);
|
|
if (!selection.TryGet(out MetricSelection? sel, out error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
Result<SeriesResult> result = await query.SeriesAsync(view.Key, new RangeRequest(range, from, to, step, tz, anchor), sel, ct);
|
|
if (!result.TryGet(out SeriesResult? series, out error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
IResult? cached = QueryHelpers.ApplyCaching(http, series.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), series.Plan.Name + string.Join(',', series.Versions.Values));
|
|
return cached ?? TypedResults.Json(Mappers.ToDto(series, live: true), ApiJsonContext.Default.SeriesDto);
|
|
});
|
|
|
|
private static Task<IResult> CompareAsync(
|
|
HttpContext http, LineService lineService, QueryService query, TimeProvider time,
|
|
[FromQuery(Name = "lines")] string? lineIds, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
|
|
QueryHelpers.GuardedAsync(http, async ct =>
|
|
{
|
|
string? list = lineIds;
|
|
if (string.IsNullOrWhiteSpace(list))
|
|
{
|
|
return Problems.From(http, ValidationError.Single("lines", "is required"));
|
|
}
|
|
|
|
var ids = new List<LineKey>();
|
|
foreach (string id in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
|
|
{
|
|
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lineService, id, ct);
|
|
if (!found.TryGet(out LineView? view, out FleetError? findError))
|
|
{
|
|
return Problems.From(http, findError);
|
|
}
|
|
|
|
ids.Add(view.Key);
|
|
}
|
|
|
|
Result<CompareResult> result = await query.CompareAsync(ids, metric, new RangeRequest(range, from, to, step, null), ct);
|
|
if (!result.TryGet(out CompareResult? compare, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
IResult? cached = QueryHelpers.ApplyCaching(http, compare.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), compare.Plan.Name + metric);
|
|
return cached ?? TypedResults.Json(
|
|
new CompareDto(
|
|
compare.Metric, Mappers.Time(compare.Range.FromMs), Mappers.Time(compare.Range.ToMs), Steps.Format(compare.Range.StepMs), compare.Plan.Name, compare.Plan.Degraded,
|
|
compare.T, compare.Values, compare.Summary),
|
|
ApiJsonContext.Default.CompareDto);
|
|
});
|
|
|
|
private static Task<IResult> DistributionAsync(
|
|
HttpContext http, LineService lines, QueryService query, TimeProvider time,
|
|
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to) =>
|
|
QueryHelpers.GuardedAsync(http, async ct =>
|
|
{
|
|
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
|
if (!found.TryGet(out LineView? view, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
var request = new RangeRequest(range, from, to, null, null);
|
|
Result<DistributionResult> result = await query.DistributionAsync(view.Key, metric, request, ct);
|
|
if (!result.TryGet(out DistributionResult? distribution, out error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
Result<(QueryRange Range, SourcePlan Plan)> planned = query.Plan(request, "15m");
|
|
IResult? cached = planned.IsSuccess ? QueryHelpers.ApplyCaching(http, planned.Value.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), metric) : null;
|
|
return cached ?? TypedResults.Json(
|
|
new DistributionDto(
|
|
distribution.Metric, distribution.Unit, [.. distribution.Buckets.Select(b => new DistributionBucketDto(b.Le, checked((long)b.Count)))],
|
|
distribution.P50, distribution.P95, distribution.P99, checked((long)distribution.Count)),
|
|
ApiJsonContext.Default.DistributionDto);
|
|
});
|
|
|
|
private static Task<IResult> HeatmapAsync(
|
|
HttpContext http, LineService lines, QueryService query, TimeProvider time,
|
|
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
|
|
QueryHelpers.GuardedAsync(http, async ct =>
|
|
{
|
|
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
|
if (!found.TryGet(out LineView? view, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
Result<HeatmapResult> result = await query.HeatmapAsync(view.Key, metric, new RangeRequest(range, from, to, step, null), ct);
|
|
if (!result.TryGet(out HeatmapResult? heatmap, out error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
IResult? cached = QueryHelpers.ApplyCaching(http, heatmap.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), heatmap.Plan.Name + metric);
|
|
return cached ?? TypedResults.Json(
|
|
new HeatmapDto(
|
|
metric, Steps.Format(heatmap.Range.StepMs), heatmap.Plan.Name, heatmap.Plan.Degraded, heatmap.T, heatmap.Edges,
|
|
[.. heatmap.Counts.Select(row => (IReadOnlyList<long>)[.. row.Select(c => checked((long)c))])]),
|
|
ApiJsonContext.Default.HeatmapDto);
|
|
});
|
|
|
|
private static async Task<IResult> TestAsync(HttpContext http, TestService tests, AgentService agents, string test_id, CancellationToken ct)
|
|
{
|
|
TestDetail? detail = await tests.GetAsync(new TestId(test_id), ct);
|
|
if (detail is null)
|
|
{
|
|
return Problems.Create(http, MoShiroErrors.TestNotFound, "test not found");
|
|
}
|
|
|
|
Result<AgentView> visible = await QueryHelpers.FindVisibleAgentAsync(http, agents, detail.Test.AgentId.Value, ct);
|
|
if (!visible.IsSuccess)
|
|
{
|
|
return Problems.Create(http, MoShiroErrors.TestNotFound, "test not found");
|
|
}
|
|
|
|
return TypedResults.Json(Mappers.ToDto(detail), ApiJsonContext.Default.TestDto);
|
|
}
|
|
|
|
private static IEnumerable<OverviewLine> Sort(IReadOnlyList<OverviewLine> lines, string? sort, string? order, MetricSelection selection)
|
|
{
|
|
string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault();
|
|
if (key is null)
|
|
{
|
|
return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal);
|
|
}
|
|
|
|
bool descending = order is not null
|
|
? order.Equals("desc", StringComparison.OrdinalIgnoreCase)
|
|
: key.StartsWith("score.", StringComparison.Ordinal) || (MetricRegistry.TryFindExport(key, out MetricDef def, out _) && def.Better == Better.Higher);
|
|
IEnumerable<OverviewLine> withValue = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is not null);
|
|
IEnumerable<OverviewLine> without = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is null);
|
|
IOrderedEnumerable<OverviewLine> ordered = descending
|
|
? withValue.OrderByDescending(l => l.Summary.Values[key])
|
|
: withValue.OrderBy(l => l.Summary.Values[key]);
|
|
return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal));
|
|
}
|
|
}
|