backend: serve single-agent task templates with their status and results, rename the kinds in storage

This commit is contained in:
justme committed 2026-10-22 07:02:28 +08:00
1 parent e77880d372
commit 3a9ea46d6f
45 files changed
+763 -216

No files matched your search

@@ -98,6 +98,7 @@ public static class ApiModule
{
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
QueryEndpoints.Map(publicGroup);
TargetEndpoints.Map(publicGroup);
LiveEndpoints.Map(publicGroup);
HostEndpoints.Map(publicGroup);
NodeEndpoints.Map(publicGroup);
@@ -25,6 +25,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(QuicLineHealthDto))]
[JsonSerializable(typeof(TestDto))]
[JsonSerializable(typeof(PageDto<TestDto>))]
[JsonSerializable(typeof(PageDto<TargetDto>))]
[JsonSerializable(typeof(PageDto<KindDto>))]
[JsonSerializable(typeof(PageDto<PlanDto>))]
[JsonSerializable(typeof(PageDto<MetricDto>))]
@@ -219,3 +219,24 @@ public sealed record LiveHostDto(string Id, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
/// <summary>
/// 一个 Agent 上的一个单个 Agent 任务:它测的目标、现在的状态和最近一段时间的时序。
/// <c>status</c>:<c>running</c> 有最近的数据;<c>waiting</c> 任务已下发、还没有数据;<c>stale</c> 有过数据但最近没有;
/// <c>rejected</c> Agent 本机拒绝执行(<c>reason</c> 是原因);<c>offline</c> Agent 不在线。
/// </summary>
public sealed record TargetDto(
string TaskId,
string AgentId,
string AgentName,
string Kind,
string Target,
string Status,
string? Reason,
DateTimeOffset? LastSample,
DateTimeOffset From,
DateTimeOffset To,
string Step,
IReadOnlyList<long> T,
IReadOnlyDictionary<string, double?[]> Values,
SummaryDto Summary);
@@ -0,0 +1,112 @@
using System.Text.Json;
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>单个 Agent 任务(Agent 自己去测一个外部地址):每个 Agent 实际在执行的目标、状态和最近的时序。</summary>
public static class TargetEndpoints
{
private const string Tag = "public";
/// <summary>最近多久内有样本算「在运行」:自适应退避到最慢是几分钟一次,所以要留够余量。</summary>
private static readonly TimeSpan s_running = TimeSpan.FromMinutes(15);
/// <summary>每种方法默认看的指标。</summary>
private static readonly Dictionary<string, string> s_metrics = new(StringComparer.Ordinal)
{
["http_request"] = "http.success,http.ttfb_ms.p50,http.ttfb_ms.p95,http.total_ms.p95,http.dns_ms.p95,http.tcp_ms.p95,http.fail.timeout,http.fail.dns,http.fail.rst,http.fail.tls,http.fail.status,http.fail.unsupported,http.fail.other",
["tcp_connect"] = "connect.success,connect.tcp_ms.p95,connect.tls_ms.p95",
["icmp_ping"] = "rtt.p50,rtt.p95,loss.total",
};
/// <summary>详情页要的指标:卡片上的那些再加上分位数和各种失败计数。</summary>
private static readonly Dictionary<string, string> s_fullMetrics = new(StringComparer.Ordinal)
{
["http_request"] = "http.success,http.ttfb_ms.p50,http.ttfb_ms.p95,http.ttfb_ms.p99,http.total_ms.p50,http.total_ms.p95,http.total_ms.p99,http.dns_ms.p95,http.tcp_ms.p95,http.fail.timeout,http.fail.dns,http.fail.rst,http.fail.tls,http.fail.status,http.fail.unsupported,http.fail.other,n.http",
["tcp_connect"] = "connect.success,connect.tcp_ms.p95,connect.tls_ms.p95,connect.fail.timeout,connect.fail.rst,connect.fail.tls,n.connect",
["icmp_ping"] = "rtt.p50,rtt.p95,rtt.p99,loss.total",
};
public static void Map(RouteGroupBuilder group) =>
group.MapGet("/targets", ListAsync)
.Api("targets_list", "Single-Agent tasks with their status and recent series", Tag)
.Returns<PageDto<TargetDto>>().ProducesProblems(403, 422, 429, 503);
/// <remarks><c>agent_id</c> 和 <c>task_id</c> 只看一个目标;<c>full=true</c> 返回详情页要的全部指标。</remarks>
private static Task<IResult> ListAsync(
HttpContext http, TargetService targets, QueryService query, BackendOptions options, TimeProvider time,
string? range, DateTimeOffset? from, DateTimeOffset? to, string? step, string? agent_id, string? task_id, bool? full) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
IReadOnlyList<TargetView> views = await targets.ListAsync(a => a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http), ct);
var items = new List<TargetDto>(views.Count);
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
foreach (TargetView view in views.Where(v => (agent_id is null || v.Agent.Agent.Id.Value == agent_id) && (task_id is null || v.Task.Id.Value == task_id)))
{
string wanted = (full == true ? s_fullMetrics : s_metrics).GetValueOrDefault(view.Task.Kind, "rtt.p50");
Result<MetricSelection> selection = MetricSelection.Resolve(wanted, null, options.MaxMetricsPerQuery);
if (!selection.TryGet(out MetricSelection? sel, out FleetError? error))
{
return Problems.From(http, error);
}
Result<TargetSeriesResult> series = await query.TargetSeriesAsync(view.Agent.Agent.Id, view.Task.Id, new RangeRequest(range ?? "3h", from, to, step, null, null), sel, ct);
if (!series.TryGet(out TargetSeriesResult? result, out error))
{
return Problems.From(http, error);
}
long? latest = await query.TargetLatestAsync(view.Agent.Agent.Id, view.Task.Id, ct);
(string status, string? reason) = StatusOf(view, latest, now);
items.Add(new TargetDto(
view.Task.Id.Value, view.Agent.Agent.Id.Value, view.Agent.Agent.Name, view.Task.Kind, view.Task.Target ?? "", status, reason,
latest is { } at ? Mappers.Time(at) : null, Mappers.Time(result.Range.FromMs), Mappers.Time(result.Range.ToMs), Steps.Format(result.Range.StepMs),
result.T, result.Values, Mappers.ToDto(result.Summary)));
}
return TypedResults.Json(new PageDto<TargetDto>(items, null), ApiJsonContext.Default.PageDtoTargetDto);
});
private static (string Status, string? Reason) StatusOf(TargetView view, long? latest, long now)
{
if (RejectedReason(view) is { } reason)
{
return ("rejected", reason);
}
if (!view.Agent.Online)
{
return ("offline", null);
}
if (latest is null)
{
return ("waiting", null);
}
return (now - latest.Value <= s_running.TotalMilliseconds ? "running" : "stale", null);
}
/// <summary>Agent 本机拒绝执行这个任务的原因(心跳里的 rejected[],条目 id 是任务 id)。</summary>
private static string? RejectedReason(TargetView view)
{
if (view.Agent.Agent.RejectedJson is not { } json)
{
return null;
}
try
{
return JsonSerializer.Deserialize(json, ApiJsonContext.Default.ListRejectedItem)?.FirstOrDefault(r => r.Id == view.Task.Id.Value)?.Reason;
}
catch (JsonException)
{
return null;
}
}
}