backend: serve single-agent task templates with their status and results, rename the kinds in storage
This commit is contained in:
1 parent
e77880d372
commit
3a9ea46d6f
45 files changed
+763
-216
No files matched your search
@@ -32,6 +32,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<NodeInfoService>();
|
||||
services.AddSingleton<LinkService>();
|
||||
services.AddSingleton<LineService>();
|
||||
services.AddSingleton<TargetService>();
|
||||
services.AddSingleton<TestService>();
|
||||
services.AddSingleton<IngestService>();
|
||||
services.AddSingleton<WindowComputer>();
|
||||
|
||||
@@ -45,3 +45,12 @@ public sealed record CompareResult(
|
||||
string Metric,
|
||||
QueryRange Range,
|
||||
SourcePlan Plan);
|
||||
|
||||
/// <summary>单个 Agent 任务(目标任务)的时序:和线路时序同一套分层与汇总,只是没有线路、事件和评分。</summary>
|
||||
public sealed record TargetSeriesResult(
|
||||
QueryRange Range,
|
||||
SourcePlan Plan,
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyDictionary<string, double?[]> Values,
|
||||
SummaryResult Summary,
|
||||
IReadOnlyDictionary<string, string> Versions);
|
||||
@@ -138,6 +138,61 @@ public sealed class QueryService
|
||||
return Result<SeriesResult>.Ok(new SeriesResult(line, p.Range, p.Plan, bucketed.T, values, summary, gaps, events, versions));
|
||||
}
|
||||
|
||||
/// <summary>某个 Agent 上一个单个 Agent 任务的时序(序列 <c>target:<任务 id></c>)。</summary>
|
||||
public async ValueTask<Result<TargetSeriesResult>> TargetSeriesAsync(NodeId agent, TaskId task, RangeRequest request, MetricSelection selection, CancellationToken ct)
|
||||
{
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "3h");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<TargetSeriesResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, agent, TargetSeries(task), ct);
|
||||
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
|
||||
{
|
||||
return Result<TargetSeriesResult>.Fail(error);
|
||||
}
|
||||
|
||||
BucketedRows bucketed = BucketedRows.Create(rows, p.Range);
|
||||
int n = p.Range.Points;
|
||||
var values = selection.ExportIds.ToDictionary(id => id, _ => new double?[n], StringComparer.Ordinal);
|
||||
double widthSeconds = p.Plan.LevelWidthMs / 1000.0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (bucketed.Sets[i] is not { } set)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, double> exported = ExportEvaluator.Evaluate(set, selection.ExportIds, bucketed.RowCounts[i] * widthSeconds);
|
||||
foreach (string id in selection.ExportIds)
|
||||
{
|
||||
if (exported.TryGetValue(id, out double v))
|
||||
{
|
||||
values[id][i] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var versions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string id in selection.ExportIds)
|
||||
{
|
||||
if (MetricRegistry.TryFindExport(id, out MetricDef def, out _))
|
||||
{
|
||||
versions[id] = def.Fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, new Dictionary<string, double>());
|
||||
return Result<TargetSeriesResult>.Ok(new TargetSeriesResult(p.Range, p.Plan, bucketed.T, values, summary, versions));
|
||||
}
|
||||
|
||||
/// <summary>这个单个 Agent 任务最后一个样本的时刻(Unix 毫秒);还没有数据返回 null。</summary>
|
||||
public ValueTask<long?> TargetLatestAsync(NodeId agent, TaskId task, CancellationToken ct) =>
|
||||
_loader.LatestSampleMsAsync(agent, TargetSeries(task), _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
private static string TargetSeries(TaskId task) => "target:" + task.Value;
|
||||
|
||||
/// <summary>这条线路最早的数据(Unix 毫秒);没有数据返回 null。</summary>
|
||||
public ValueTask<long?> EarliestAsync(LineKey line, CancellationToken ct) =>
|
||||
_loader.EarliestSampleMsAsync(line.Listener, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Fleet.Server;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>一个 Agent 上正在(或应该)运行的单个 Agent 任务:这个 Agent 的有效任务集里 <c>scope=target</c> 的那些。</summary>
|
||||
public sealed record TargetView(AgentView Agent, EffectiveTask Task);
|
||||
|
||||
/// <summary>单个 Agent 任务目录:按 Agent 展开模板、覆盖和专属任务,列出每个 Agent 实际执行的目标。</summary>
|
||||
public sealed class TargetService
|
||||
{
|
||||
private readonly AgentService _agents;
|
||||
private readonly ITaskStore _tasks;
|
||||
|
||||
public TargetService(AgentService agents, ITaskStore tasks)
|
||||
{
|
||||
_agents = agents;
|
||||
_tasks = tasks;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TargetView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TaskRecord> all = await _tasks.ListAsync(ct);
|
||||
var result = new List<TargetView>();
|
||||
foreach (AgentView view in await _agents.ListAsync(ct))
|
||||
{
|
||||
if (view.Agent.Status != NodeStatus.Active || !include(view))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EffectiveTask task in EffectiveTasks.Compute(all, view.Agent, filterByAgentKinds: false).Where(t => t.Scope == EffectiveTasks.TargetScope))
|
||||
{
|
||||
result.Add(new TargetView(view, task));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public sealed record EffectiveTask(
|
||||
ParamValues Resolved);
|
||||
|
||||
/// <summary>
|
||||
/// 有效任务集:线路任务模板 + 针对单个 Agent 的覆盖 + 该 Agent 的目标任务。
|
||||
/// 有效任务集:任务模板(线路任务、单个 Agent 的任务)+ 针对单个 Agent 的覆盖 + 只属于该 Agent 的目标任务。
|
||||
/// 参数经 ParamSpec 校验并补全默认值,Backend 和节点使用同一套规则。
|
||||
/// </summary>
|
||||
public static class EffectiveTasks
|
||||
@@ -35,23 +35,23 @@ public static class EffectiveTasks
|
||||
|
||||
foreach (TaskRecord task in all.OrderBy(t => t.Id.Value, StringComparer.Ordinal))
|
||||
{
|
||||
if (task.Scope == LineScope && task.AgentId is null)
|
||||
if (task.AgentId is null)
|
||||
{
|
||||
// 模板:有覆盖时以覆盖为准(覆盖可以停用或改参数)
|
||||
// 模板(线路任务或单个 Agent 的任务):有覆盖时以覆盖为准(覆盖可以停用、改参数,单个 Agent 的任务还可以换目标)
|
||||
TaskRecord? over = overrides.GetValueOrDefault(task.Id);
|
||||
if (over is not null)
|
||||
{
|
||||
if (over.Enabled)
|
||||
{
|
||||
Add(result, over.Id, task.Scope, over.Kind, null, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds);
|
||||
Add(result, over.Id, task.Scope, over.Kind, over.Target ?? task.Target, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
else if (task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, null, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.Enabled)
|
||||
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.BaseTaskId is null && task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class TaskService
|
||||
public ValueTask<TaskRecord?> GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct);
|
||||
|
||||
/// <summary>
|
||||
/// 创建默认的线路任务模板,这样新 Agent 注册后自动获得任务:udp_echo、tcp_echo 持续运行,connect(TCP 建连和 TLS 握手耗时,
|
||||
/// 创建默认的线路任务模板,这样新 Agent 注册后自动获得任务:quic_ping、tcp_ping 持续运行,connect(TCP 建连和 TLS 握手耗时,
|
||||
/// 每分钟 6 次)也持续运行。每一批默认任务只种一次,用设置里的标记记住;你删掉的默认模板不会被重新建出来,
|
||||
/// 后来新增的默认任务(connect)在已有部署升级后会补建一次。
|
||||
/// </summary>
|
||||
@@ -49,13 +49,13 @@ public sealed class TaskService
|
||||
{
|
||||
if (await _settings.GetAsync("tasks.seeded", ct) is null)
|
||||
{
|
||||
await SeedAsync([Kinds.UdpEcho, Kinds.TcpEcho], ct);
|
||||
await SeedAsync([Kinds.QuicPing, Kinds.TcpPing], ct);
|
||||
await _settings.SetAsync("tasks.seeded", "1", ct);
|
||||
}
|
||||
|
||||
if (await _settings.GetAsync("tasks.seeded.connect", ct) is null)
|
||||
{
|
||||
await SeedAsync([Kinds.Connect], ct);
|
||||
await SeedAsync([Kinds.TcpConnect], ct);
|
||||
await _settings.SetAsync("tasks.seeded.connect", "1", ct);
|
||||
}
|
||||
}
|
||||
@@ -223,9 +223,31 @@ public sealed class TaskService
|
||||
|
||||
if (scope == EffectiveTasks.TargetScope)
|
||||
{
|
||||
if (agentId is null || !TryNormalizeTarget(target, out string? normalized))
|
||||
// 单个 Agent 的任务:没有 agent_id 是模板(所有 Agent 各自对这个目标执行),有 agent_id 是只属于这个 Agent 的任务,
|
||||
// 或者(带 base_task_id)覆盖模板的参数、停用它、换一个目标
|
||||
if (baseTaskId is { } templateId)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port");
|
||||
TaskRecord? template = await _tasks.GetAsync(templateId, ct);
|
||||
if (agentId is null || template is null || template.AgentId is not null || template.Scope != EffectiveTasks.TargetScope)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("base_task_id", "must reference a single-agent template and requires agent_id");
|
||||
}
|
||||
|
||||
if (template.Kind != kind)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("kind", "an override must use the same kind as its template");
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(null));
|
||||
}
|
||||
}
|
||||
|
||||
string? normalized = TargetHost.Normalize(kind, target);
|
||||
if (normalized is null)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", kind == TargetHost.HttpKind ? "must be an http:// or https:// URL" : "must be a host name or IP address with an optional port");
|
||||
}
|
||||
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(normalized));
|
||||
@@ -288,28 +310,4 @@ public sealed class TaskService
|
||||
|
||||
return Result<bool>.Ok(true);
|
||||
}
|
||||
|
||||
/// <summary>只接受 IP 地址(可带端口),不做 DNS 解析,避免把 Agent 用作解析器。</summary>
|
||||
public static bool TryNormalizeTarget(string? target, out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(target, out IPAddress? ip))
|
||||
{
|
||||
normalized = ip.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint) && endpoint.Port is > 0 and <= 65535)
|
||||
{
|
||||
normalized = endpoint.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public static class Plans
|
||||
private static JsonElement S(string value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.String);
|
||||
|
||||
private static PhaseSpec Train(double mbps) =>
|
||||
new("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10);
|
||||
new("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10);
|
||||
|
||||
public static readonly PlanSpec Hy2Ladder = new(
|
||||
"hy2_ladder",
|
||||
@@ -31,9 +31,9 @@ public static class Plans
|
||||
"REALITY session",
|
||||
"Connection establishment, a short (4 s) bulk transfer for the peak throughput and a held connection.",
|
||||
[
|
||||
new PhaseSpec("connect", new Dictionary<string, JsonElement> { ["per_minute"] = N(20) }, 30),
|
||||
new PhaseSpec("tcp_bulk", new Dictionary<string, JsonElement> { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4),
|
||||
new PhaseSpec("hold", new Dictionary<string, JsonElement> { ["heartbeat_s"] = N(5) }, 30),
|
||||
new PhaseSpec("tcp_connect", new Dictionary<string, JsonElement> { ["per_minute"] = N(20) }, 30),
|
||||
new PhaseSpec("tcp_transfer", new Dictionary<string, JsonElement> { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4),
|
||||
new PhaseSpec("tls_hold", new Dictionary<string, JsonElement> { ["heartbeat_s"] = N(5) }, 30),
|
||||
]);
|
||||
|
||||
public static readonly IReadOnlyList<PlanSpec> All = [Hy2Ladder, RealitySession];
|
||||
|
||||
Reference in new issue
Block a user