4 Commits
Author SHA1 Message Date
justme caee03b8cc web: show single-agent targets on the visitor page and open a detail page from the card [CI]
ci/woodpecker/push/dev-backend-image Pipeline is running
ci/woodpecker/push/dev-image Pipeline is running
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
2026-10-22 07:02:31 +08:00
justme 7ad226f29c web: split the task page into agent-to-agent and single-agent tasks with templates and run status 2026-10-22 07:02:31 +08:00
justme 3a9ea46d6f backend: serve single-agent task templates with their status and results, rename the kinds in storage 2026-10-22 07:02:28 +08:00
justme e77880d372 measurement: name the kinds by protocol and action, add the http_request kind and QUIC settings 2026-10-22 07:02:23 +08:00
88 changed files with 2568 additions and 522 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<Http3Support>true</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
+8 -2
View File
@@ -74,10 +74,16 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
_ipv6Supported = _listening ? bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 : null;
_admission = new AgentAdmission(_streams);
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate);
QuicSettings quic = config.Quic.ToSettings();
if (quic.Validate() is { } invalid)
{
throw new InvalidOperationException("agent.json quic: " + invalid);
}
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate, quic);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, quic, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
@@ -15,6 +15,9 @@ public sealed class AgentConfig
[JsonPropertyName("listen")]
public ListenSection Listen { get; set; } = new();
[JsonPropertyName("quic")]
public QuicSection Quic { get; set; } = new();
[JsonPropertyName("limits")]
public LimitsSection Limits { get; set; } = new();
@@ -66,6 +69,27 @@ public sealed class ListenSection
public List<int> TcpPorts { get; set; } = [7444];
}
/// <summary>数据通道 QUIC 连接的传输参数;不写的项用 MsQuic 默认值。监听和拨出的连接都用这里的值。</summary>
public sealed class QuicSection
{
[JsonPropertyName("min_mtu")]
public int? MinMtu { get; set; }
[JsonPropertyName("max_mtu")]
public int? MaxMtu { get; set; }
[JsonPropertyName("pacing")]
public bool? Pacing { get; set; }
[JsonPropertyName("idle_timeout_ms")]
public int? IdleTimeoutMs { get; set; }
[JsonPropertyName("keep_alive_ms")]
public int? KeepAliveMs { get; set; }
public QuicSettings ToSettings() => new() { MinMtu = MinMtu, MaxMtu = MaxMtu, Pacing = Pacing, IdleTimeoutMs = IdleTimeoutMs, KeepAliveMs = KeepAliveMs };
}
public sealed class LimitsSection
{
[JsonPropertyName("max_total_mbps")]
@@ -217,8 +217,8 @@ public sealed class StreamInfo
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kind, parameters),
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kind, parameters),
"quic_stream" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
@@ -11,7 +11,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>所有连接共用的东西。</summary>
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, QuicSettings Quic, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点连接,分两层:
@@ -170,7 +170,8 @@ public sealed class DialConnection : IDisposable
new DataPlaneCounters(),
_attemptLog,
new IPEndPoint(remote.Address, quicPort),
agentKey);
agentKey,
_env.Quic);
Volatile.Write(ref _activeDatagrams, datagrams);
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -55,7 +55,7 @@ public sealed class DialSession
}
}
private static bool IsTcpKind(string kind) => kind is "tcp_echo" or "connect" or "hold" or "tcp_bulk";
private static bool IsTcpKind(string kind) => kind is "tcp_ping" or "tcp_connect" or "tls_hold" or "tcp_transfer";
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
@@ -137,7 +137,7 @@ public sealed class DialSession
return false;
}
if (start.Kind == "udp_train")
if (start.Kind == "quic_stream")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
@@ -167,7 +167,7 @@ public sealed class DialSession
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "quic_stream" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
@@ -7,14 +7,16 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 <c>allowed_target_cidrs</c> 内
/// (由 <see cref="BudgetGuard"/> 在接受任务之前检查)。
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_ping、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标可以是 IP、域名或(http)URL;域名在 Agent 上解析,
/// 每个解析出的地址都必须在本地 <c>allowed_target_cidrs</c> 内(IP 目标由 <see cref="BudgetGuard"/> 在接受任务之前检查,域名在这里检查)。
/// </summary>
public sealed class TargetRunners
{
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_resolveRetry = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, Running> _running = [];
private readonly object _gate = new();
private readonly ChunkAssembler _chunks;
@@ -33,7 +35,7 @@ public sealed class TargetRunners
}
/// <summary>让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。</summary>
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired, Func<IPAddress, bool>? allow = null)
{
lock (_gate)
{
@@ -56,7 +58,7 @@ public sealed class TargetRunners
var cts = new CancellationTokenSource();
_running[id] = new Running(Signature(spec), cts);
_ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
_ = Task.Run(() => RunAsync(spec, decision, allow, cts.Token), CancellationToken.None);
}
}
}
@@ -64,23 +66,33 @@ public sealed class TargetRunners
private static string Signature(TaskSpec spec) =>
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
private async Task RunAsync(TaskSpec spec, TaskDecision decision, Func<IPAddress, bool>? allow, CancellationToken ct)
{
try
{
if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
if (!TargetHost.TryParse(spec.Target, out string host, out int targetPort, out _))
{
_log.Warn("target is not an address", ("task", spec.Id.Value));
_log.Warn("target is not valid", ("task", spec.Id.Value));
return;
}
// http 自己解析并检查每个地址;其余 Kind 在这里解析出一个允许的地址作为对端
IPAddress? address = null;
if (spec.Kind != TargetHost.HttpKind)
{
address = await ResolveAllowedAsync(spec, host, allow, ct);
}
int port = targetPort != 0 ? targetPort : spec.Kind == "tcp_connect" ? (int)decision.Resolved!.GetInt("port") : 0;
var env = new BlockEnv
{
Clock = _clock,
Log = _log,
Session = () => null,
PeerAddress = address,
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
Target = spec.Target,
AllowAddress = allow,
Fragments = new LocalSink(this, spec, address?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
};
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -110,21 +122,29 @@ public sealed class TargetRunners
}
}
private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
/// <summary>解析目标,取第一个允许的地址;解析失败或没有允许的地址就每分钟重试(域名的解析结果会变)。</summary>
private async Task<IPAddress> ResolveAllowedAsync(TaskSpec spec, string host, Func<IPAddress, bool>? allow, CancellationToken ct)
{
port = kind == "connect" ? (int)values.GetInt("port") : 0;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
while (true)
{
address = endpoint.Address;
if (endpoint.Port != 0)
try
{
port = endpoint.Port;
IPAddress[] addresses = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
IPAddress? chosen = addresses.FirstOrDefault(a => allow?.Invoke(a) ?? true);
if (chosen is not null)
{
return chosen;
}
_log.Warn("no address of the target is allowed", ("task", spec.Id.Value), ("host", host));
}
catch (System.Net.Sockets.SocketException ex)
{
_log.Warn("target did not resolve", ("task", spec.Id.Value), ("host", host), ("err", ex.Message));
}
return true;
await Task.Delay(s_resolveRetry, _time, ct);
}
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
@@ -15,7 +15,7 @@ namespace MoShiro.Agent;
/// </summary>
public sealed class TaskManager
{
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(quic_stream、tcp_transfer 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
@@ -151,7 +151,7 @@ public sealed class TaskManager
}
}
_targets.Reconcile(targets);
_targets.Reconcile(targets, guard.AddressAllowed);
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
@@ -206,7 +206,7 @@ public sealed class TestRunner
}
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
if (phase.Kind == "udp_train")
if (phase.Kind == "quic_stream")
{
try
{
+1 -1
View File
@@ -55,7 +55,7 @@ public sealed class FleetLinkOptions
/// <summary>服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 <see cref="VersionRetry"/> 再握手,服务端可能正在更新。</summary>
public Func<string?>? MinServerVersion { get; init; }
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary>
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:quic_ping,tcp_ping</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
@@ -76,7 +76,7 @@ public abstract class BlockBase : IBlock
}
}
/// <summary>读这个任务的自适应频率参数(<c>udp_echo</c> 和 <c>tcp_echo</c> 共用)。</summary>
/// <summary>读这个任务的自适应频率参数(<c>quic_ping</c> 和 <c>tcp_ping</c> 共用)。</summary>
protected AdaptiveRate? CreateAdaptiveRate()
{
if (!Spec.Bool("adaptive"))
@@ -26,13 +26,14 @@ public static class BlockCatalog
{
public static readonly IReadOnlyList<BlockPair> All =
[
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
new("quic_ping", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
new("tcp_ping", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("quic_stream", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_transfer", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
new("tcp_connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("tls_hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("http_request", (e, s) => new HttpDialer(e, s), null, RunsAsTarget: true),
new("icmp_ping", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
];
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
@@ -0,0 +1,382 @@
using System.Net;
using System.Net.Http;
using System.Net.Quic;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>http 请求的结果,编码与 Backend 的 <c>HttpOutcome</c> 一致。</summary>
internal static class HttpOutcomes
{
public const int Ok = 0;
public const int Timeout = 1;
public const int Reset = 2;
public const int Tls = 3;
public const int Other = 4;
public const int Dns = 5;
public const int BadStatus = 6;
public const int Unsupported = 7;
public const int NotAllowed = 8;
}
/// <summary>解析出的地址都不在本地允许的网段内。</summary>
internal sealed class TargetNotAllowedException : Exception
{
public TargetNotAllowedException(string host)
: base("no address of " + host + " is allowed by allowed_target_cidrs")
{
}
}
/// <summary>
/// 单个 Agent 去请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3,不回退),记录 DNS、TCP 建连、首字节、总耗时和状态码。
/// 每次请求新建连接,所以每次都包含完整的建连过程;频率按失败率自适应:目标一直应答就降到证明得了成功率的最低频率,
/// 出现失败升到最高频率,全部失败时指数退避。
/// </summary>
public sealed class HttpDialer : BlockBase
{
private readonly DriverLog _log;
private readonly int _attempt;
private readonly int _ts;
private readonly int _w;
private readonly int _dns;
private readonly int _tcp;
private readonly int _ttfb;
private readonly int _total;
private readonly int _status;
private readonly int _proto;
private readonly int _outcome;
private readonly HashSet<int> _loggedOutcomes = [];
private long _next;
public HttpDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("req")!, Side.Dialer, 1024);
_attempt = _log.Store.ColumnIndex("attempt");
_ts = _log.Store.ColumnIndex("ts");
_w = _log.Store.ColumnIndex("w");
_dns = _log.Store.ColumnIndex("dns_us");
_tcp = _log.Store.ColumnIndex("tcp_us");
_ttfb = _log.Store.ColumnIndex("ttfb_us");
_total = _log.Store.ColumnIndex("total_us");
_status = _log.Store.ColumnIndex("status");
_proto = _log.Store.ColumnIndex("proto");
_outcome = _log.Store.ColumnIndex("outcome");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
if (!TargetHost.TryParse(Env.Target, out _, out _, out Uri? url) || url is null)
{
Env.Log.Warn("http target is not a URL", ("task", Spec.Scope.Task?.Value));
await WaitUntilCancelledAsync(ct);
return;
}
AdaptiveRate? rate = CreateRequestRate();
try
{
if (rate is not null)
{
await PaceAsync(rate, async () =>
{
int weight = rate.Weight;
long sent = Env.Clock.NowUs();
rate.OnSend(sent);
bool ok = await RequestAsync(url, weight, ct);
if (ok)
{
long now = Env.Clock.NowUs();
rate.OnReply(sent, now, (now - sent) / 1000.0);
}
}, ct);
}
else
{
TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
while (!ct.IsCancellationRequested)
{
long start = Env.Clock.NowUs();
await RequestAsync(url, 1, ct);
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
/// <summary>
/// 频率以「次/分钟」声明,换算成 Hz 交给 <see cref="AdaptiveRate"/>。请求间隔以秒到分钟计,所以窗口按分钟级放宽,
/// 并且不用 RTT 分位数的精度保护(样本太少,估计不出来):平稳时只由成功率的上界决定频率。
/// </summary>
private AdaptiveRate? CreateRequestRate()
{
if (!Spec.Bool("adaptive"))
{
return null;
}
double max = Math.Max(Spec.Int("rate_max_per_min"), Spec.Int("rate_min_per_min")) / 60.0;
return new AdaptiveRate(new AdaptiveRateOptions
{
MinHz = Spec.Int("rate_min_per_min") / 60.0,
MaxHz = max,
TauPct = Spec.Number("tau_pct"),
RttGuard = false,
CertifySeconds = 900,
QuietCertifySeconds = 1200,
GraceSeconds = Math.Ceiling(Spec.Int("timeout_ms") / 1000.0) + 1,
DeadWindowSeconds = 120,
DeadMinSent = 3,
DeadMaxIntervalSeconds = 300,
ActiveLossCount = 2,
Prior = Env.LossPrior,
});
}
/// <summary>发一次请求并记一行;返回这次算不算成功(有应答且状态码合格)。</summary>
private async Task<bool> RequestAsync(Uri url, int weight, CancellationToken ct)
{
string version = Spec.Text("version");
bool verify = Spec.Bool("tls_verify");
TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
var phases = new Phases();
long start = Env.Clock.NowUs();
int outcome = HttpOutcomes.Other;
int status = 0;
int proto = 0;
long? ttfb = null;
long? total = null;
using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
if (version == "h3" && !QuicConnection.IsSupported)
{
outcome = HttpOutcomes.Unsupported;
LogFirstFailure(outcome, url, version, null);
}
else
{
if (version == "h3")
{
await ResolveForQuicAsync(url.IdnHost, phases, linked.Token);
}
using HttpMessageInvoker invoker = CreateInvoker(version, verify, phases);
using var request = new HttpRequestMessage(new HttpMethod(Spec.Text("method")), url)
{
Version = version switch { "h1" => HttpVersion.Version11, "h2" => HttpVersion.Version20, _ => HttpVersion.Version30 },
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using HttpResponseMessage response = await invoker.SendAsync(request, linked.Token);
ttfb = Env.Clock.NowUs() - start;
status = (int)response.StatusCode;
proto = response.Version.Major switch { 3 => 3, 2 => 2, _ => 1 };
await ReadBodyAsync(response, (int)Spec.Int("read_kb") * 1024, linked.Token);
total = Env.Clock.NowUs() - start;
long expected = Spec.Int("ok_status");
outcome = (expected == 0 ? status < 400 : status == expected) ? HttpOutcomes.Ok : HttpOutcomes.BadStatus;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
outcome = HttpOutcomes.Timeout;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or SocketException or AuthenticationException or TargetNotAllowedException or QuicException)
{
outcome = Classify(ex);
LogFirstFailure(outcome, url, version, ex);
}
Record(start, weight, phases, ttfb, total, status, proto, outcome);
return outcome == HttpOutcomes.Ok;
}
private HttpMessageInvoker CreateInvoker(string version, bool verify, Phases phases)
{
var handler = new SocketsHttpHandler
{
UseProxy = false,
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
// 设置了 ConnectCallback 的处理器不能用 HTTP/3(运行时用自己的 QUIC 连接,不经过回调):h3 只能靠请求前的解析和网段检查
if (version != "h3")
{
handler.ConnectCallback = (context, token) => ConnectAsync(context.DnsEndPoint, phases, token);
}
if (!verify)
{
#pragma warning disable CA5359 // 操作员明确关闭证书校验(tls_verify=false):只测可达性和耗时
handler.SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true };
#pragma warning restore CA5359
}
return new HttpMessageInvoker(handler, disposeHandler: true);
}
/// <summary>自己解析和连接:每个地址都要在允许的网段里,DNS 和 TCP 建连的耗时分别记下来。</summary>
private async ValueTask<Stream> ConnectAsync(DnsEndPoint endpoint, Phases phases, CancellationToken ct)
{
IPAddress[] addresses = await ResolveAsync(endpoint.Host, phases, ct);
long start = Env.Clock.NowUs();
Exception? last = null;
foreach (IPAddress address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), ct);
phases.TcpUs = Env.Clock.NowUs() - start;
return new NetworkStream(socket, ownsSocket: true);
}
catch (SocketException ex)
{
socket.Dispose();
last = ex;
}
}
throw last ?? new SocketException((int)SocketError.HostNotFound);
}
/// <summary>HTTP/3 由运行时自己建 UDP 连接,没法接管;先解析并检查一遍,再让它去连。</summary>
private async Task ResolveForQuicAsync(string host, Phases phases, CancellationToken ct) => _ = await ResolveAsync(host, phases, ct);
private async Task<IPAddress[]> ResolveAsync(string host, Phases phases, CancellationToken ct)
{
long start = Env.Clock.NowUs();
IPAddress[] all = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
phases.DnsUs = Env.Clock.NowUs() - start;
IPAddress[] allowed = [.. all.Where(a => Env.AllowAddress?.Invoke(a) ?? true)];
return allowed.Length > 0 ? allowed : throw new TargetNotAllowedException(host);
}
private static async Task ReadBodyAsync(HttpResponseMessage response, int limit, CancellationToken ct)
{
if (limit <= 0)
{
return;
}
await using Stream body = await response.Content.ReadAsStreamAsync(ct);
byte[] buffer = new byte[Math.Min(limit, 16 * 1024)];
int read = 0;
while (read < limit)
{
int n = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limit - read)), ct);
if (n == 0)
{
break;
}
read += n;
}
}
/// <summary>每种失败结果只记第一次(带原因),保留给事后审计,不会随请求频率刷屏。</summary>
private void LogFirstFailure(int outcome, Uri url, string version, Exception? ex)
{
if (!_loggedOutcomes.Add(outcome))
{
return;
}
Env.Log.Warn("http request failed", ("task", Spec.Scope.Task?.Value), ("outcome", outcome), ("version", version), ("host", url.Host),
("quic_supported", version == "h3" ? QuicConnection.IsSupported : null), ("err", ex is null ? "System.Net.Quic is not available" : ex.GetType().Name + ": " + (ex.InnerException?.Message ?? ex.Message)));
}
private static int Classify(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
switch (e)
{
case TargetNotAllowedException:
return HttpOutcomes.NotAllowed;
case AuthenticationException:
return HttpOutcomes.Tls;
case SocketException { SocketErrorCode: SocketError.HostNotFound or SocketError.TryAgain or SocketError.NoData }:
return HttpOutcomes.Dns;
case SocketException { SocketErrorCode: SocketError.ConnectionRefused or SocketError.ConnectionReset }:
return HttpOutcomes.Reset;
case HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError }:
return HttpOutcomes.Dns;
case HttpRequestException { HttpRequestError: HttpRequestError.SecureConnectionError }:
return HttpOutcomes.Tls;
case HttpRequestException { HttpRequestError: HttpRequestError.VersionNegotiationError }:
return HttpOutcomes.Unsupported;
}
}
return HttpOutcomes.Other;
}
private void Record(long startUs, int weight, Phases phases, long? ttfb, long? total, int status, int proto, int outcome)
{
long key = _log.Store.Append();
_log.Store.Set(key, _attempt, _next++);
_log.Store.Set(key, _ts, startUs);
_log.Store.Set(key, _w, Math.Clamp(weight, 1, 65535));
_log.Store.Set(key, _outcome, outcome);
SetIf(key, _dns, phases.DnsUs);
SetIf(key, _tcp, phases.TcpUs);
SetIf(key, _ttfb, ttfb);
SetIf(key, _total, total);
if (status != 0)
{
_log.Store.Set(key, _status, status);
_log.Store.Set(key, _proto, proto);
}
}
private void SetIf(long key, int column, long? value)
{
if (value is { } v)
{
_log.Store.Set(key, column, Math.Clamp(v, 0, uint.MaxValue));
}
}
/// <summary>一次请求里 DNS 和 TCP 建连用了多久(连接回调里填)。</summary>
private sealed class Phases
{
public long? DnsUs { get; set; }
public long? TcpUs { get; set; }
}
}
@@ -219,7 +219,7 @@ public sealed class TrainDialer : BlockBase
[FragmentKeys.SeqTo] = Number(to),
};
Env.Fragments!.Ship(
"udp_train",
"quic_stream",
Spec.Scope,
fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt],
parameters,
@@ -65,6 +65,9 @@ public sealed record AdaptiveRateOptions
public double RttFloorMs { get; init; } = 5;
/// <summary>用 RTT p99 的置信区间限制降频。样本稀少的任务(每分钟几次)估不出区间,关掉后平稳频率只由丢包上界决定。</summary>
public bool RttGuard { get; init; } = true;
/// <summary>先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。</summary>
public Func<long, double?>? Prior { get; init; }
@@ -285,7 +288,7 @@ public sealed class AdaptiveRate
UpdateGuard(nowUs);
double hz = next switch
{
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), !_options.RttGuard ? _options.MinHz : double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
ProbeState.Watch or ProbeState.Active => _options.MaxHz,
_ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
};
@@ -190,6 +190,8 @@ public sealed class BlockSpec
public bool Bool(string name) => Params.GetBool(name);
public string Text(string name) => Params.GetString(name);
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
public string ConfigSignature => KindId + "|" + Port + "|" +
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
@@ -237,6 +239,12 @@ public sealed class BlockEnv
/// <summary>拨号方:对端(Agent,或目标任务的目标地址)。</summary>
public IPAddress? PeerAddress { get; init; }
/// <summary>单个 Agent 任务的目标原文(http 是 URL,其余是主机[:端口]);线路任务为 null。</summary>
public string? Target { get; init; }
/// <summary>单个 Agent 任务:解析出来的地址能不能连(本地 <c>allowed_target_cidrs</c>);null 表示不限制。</summary>
public Func<IPAddress, bool>? AllowAddress { get; init; }
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
@@ -246,7 +254,7 @@ public sealed class BlockEnv
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
/// <summary>本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
/// <summary>本地硬上限:批量传输(tcp_transfer)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
public double MaxBulkMbps { get; init; } = 100;
/// <summary>本地硬上限:单向数据流接收缓冲的包速率上界。</summary>
@@ -267,6 +275,8 @@ public sealed class BlockEnv
Counters = Counters,
TcpListen = TcpListen,
PeerAddress = PeerAddress,
Target = Target,
AllowAddress = AllowAddress,
PeerSpkiSha256 = PeerSpkiSha256,
KnockKey = KnockKey,
Fragments = Fragments,
@@ -106,22 +106,25 @@ public sealed class BudgetGuard
return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary<string, JsonElement>(parameters) }, ref mbps, ref pps);
}
/// <summary>
/// 目标在静态上可不可以接受:IP 字面量按 <c>allowed_target_cidrs</c> 检查;域名(包括 URL 里的)要解析之后才知道,
/// 这里只要求配置过白名单,运行时每次解析出的地址再用 <see cref="AddressAllowed"/> 检查。
/// </summary>
public bool TargetAllowed(string? target)
{
if (target is null)
if (!TargetHost.TryParse(target, out string? host, out _, out _))
{
return false;
}
string host = target;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
host = endpoint.Address.ToString();
}
return IPAddress.TryParse(host, out IPAddress? address) ? AddressAllowed(address) : _cidrs.Count > 0;
}
if (!IPAddress.TryParse(host, out IPAddress? address))
public bool AddressAllowed(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
return false;
address = address.MapToIPv4();
}
foreach ((IPAddress network, int prefix) in _cidrs)
@@ -148,10 +151,11 @@ public sealed class BudgetGuard
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kindId, values) * 2,
"icmp_echo" => values.GetInt("rate_hz") * 2,
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"connect" => values.GetInt("per_minute") * 6 / 60,
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kindId, values) * 2,
"icmp_ping" => values.GetInt("rate_hz") * 2,
"quic_stream" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"tcp_connect" => values.GetInt("per_minute") * 6 / 60,
"http_request" => Math.Max(1, values.GetInt("rate_max_per_min") * 20 / 60),
_ => 1,
};
@@ -109,14 +109,16 @@ internal static unsafe class MsQuicApi
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
public static nint ConfigurationOpen(nint registration, byte[] alpn)
public static nint ConfigurationOpen(nint registration, byte[] alpn, QuicSettings? settings = null)
{
byte[] native = settings is { IsDefault: false } ? settings.ToNative() : [];
unsafe
{
fixed (byte* alpnBytes = alpn)
fixed (byte* settingsBytes = native)
{
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
uint status = s_configurationOpen(registration, ref buffer, 1, native.Length == 0 ? 0 : (nint)settingsBytes, (uint)native.Length, 0, out nint configuration);
Check(status, "ConfigurationOpen");
return configuration;
}
@@ -156,7 +156,8 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
byte[] expectedPeerSpki,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
CancellationToken ct)
CancellationToken ct,
QuicSettings? settings = null)
{
nint registration = 0;
nint configuration = 0;
@@ -165,7 +166,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
try
{
registration = MsQuicApi.RegistrationOpen("moshiro-dialer");
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value, settings);
LoadClientCredentials(configuration);
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
SocketAddress serialized = endpoint.Serialize();
@@ -21,6 +21,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
private readonly INodeLog _log;
private readonly Action<IPEndPoint>? _peerAddressChanged;
private readonly X509Certificate2 _certificate;
private readonly QuicSettings? _settings;
private readonly GCHandle _self;
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
private readonly List<nint> _listeners = [];
@@ -34,8 +35,10 @@ internal sealed class MsQuicListener : IAsyncDisposable
X509Certificate2 certificate,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
Action<IPEndPoint>? peerAddressChanged)
Action<IPEndPoint>? peerAddressChanged,
QuicSettings? settings = null)
{
_settings = settings;
// MsQuic opens dual-mode wildcard sockets, but filters incoming connections using
// the address family supplied to ListenerStart. An IPv6Any listener alone rejects
// IPv4-mapped connections with TLS alert 120 (no_application_protocol).
@@ -62,7 +65,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
try
{
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value, _settings);
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
var bound = new List<int>(_ports.Count * _bindAddresses.Count);
foreach (int port in _ports)
@@ -21,6 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private readonly DataSession? _clientSession;
private readonly IPEndPoint? _remote;
private readonly byte[]? _peerSpki;
private readonly QuicSettings? _settings;
private int _previousSourcePort;
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MsQuicListener? _listener;
@@ -28,8 +29,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private int _disposed;
/// <summary>Constructs an Agent-side QUIC listener.</summary>
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate, QuicSettings? settings = null)
{
_settings = settings;
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -39,8 +41,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
}
/// <summary>Constructs a 拨号方-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki)
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, QuicSettings? settings = null)
{
_settings = settings;
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -73,7 +76,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
throw new InvalidOperationException("QUIC listener is already bound.");
}
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null, _settings);
_listener.Start();
BoundPorts = _listener.BoundPorts;
}
@@ -92,7 +95,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await CloseClientAsync().ConfigureAwait(false);
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct).ConfigureAwait(false);
MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct, _settings).ConfigureAwait(false);
_client = client;
_clientSession.BindDatagramConnection(client);
BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
@@ -0,0 +1,110 @@
using System.Buffers.Binary;
namespace MoShiro.Measurement;
/// <summary>
/// 数据通道 QUIC 连接的传输参数(<c>agent.json</c> 的 <c>quic</c> 段);null 的项用 MsQuic 默认值。
/// 一条 QUIC 连接由这个 Agent 与对端之间的所有任务共用,所以这些参数属于节点,不属于某个任务。
/// 监听方用它接受连接,拨号方用它发起连接,两边各自生效。
/// </summary>
public sealed record QuicSettings
{
/// <summary>MsQuic 默认的最小、最大 MTU 是 1248 和 1500(IP 包大小,含 IP 和 UDP 头)。</summary>
public const int MinAllowedMtu = 1200;
public const int MaxAllowedMtu = 65535;
/// <summary>路径 MTU 探测的起点;链路的真实 MTU 更小时探测会往下收缩,更大时往上探测到 <see cref="MaxMtu"/>。</summary>
public int? MinMtu { get; init; }
/// <summary>路径 MTU 探测的上限。设成链路的真实 MTU 可以省掉探测,设小可以避开会丢大包的路径。</summary>
public int? MaxMtu { get; init; }
public bool? Pacing { get; init; }
public int? IdleTimeoutMs { get; init; }
public int? KeepAliveMs { get; init; }
public bool IsDefault => MinMtu is null && MaxMtu is null && Pacing is null && IdleTimeoutMs is null && KeepAliveMs is null;
/// <summary>参数是否合法;不合法返回原因。</summary>
public string? Validate()
{
if (MinMtu is { } min && min is < MinAllowedMtu or > MaxAllowedMtu)
{
return $"min_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
}
if (MaxMtu is { } max && max is < MinAllowedMtu or > MaxAllowedMtu)
{
return $"max_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
}
if (MinMtu is { } lo && MaxMtu is { } hi && lo > hi)
{
return "min_mtu must not exceed max_mtu";
}
if (IdleTimeoutMs is < 0)
{
return "idle_timeout_ms must not be negative";
}
return KeepAliveMs is < 0 ? "keep_alive_ms must not be negative" : null;
}
// QUIC_SETTINGS(msquic.h)的布局:IsSetFlags(u64) 后面依次是 u64、u32、u16 各一组字段,再是位域字节。
// 这里只写用到的几项,其余字段保持 0 且不置位。长度只算到 MtuDiscoveryMissingProbeCount,更靠后的字段 MsQuic 不会读。
private const int Size = 112;
private const int IdleTimeoutOffset = 24;
private const int KeepAliveOffset = 88;
private const int MinMtuOffset = 102;
private const int MaxMtuOffset = 104;
private const int BitFieldOffset = 106;
private const int PacingBit = 1 << 1;
private const int IdleTimeoutFlag = 2;
private const int KeepAliveFlag = 16;
private const int MinMtuFlag = 22;
private const int MaxMtuFlag = 23;
private const int PacingFlag = 25;
internal byte[] ToNative()
{
byte[] native = new byte[Size];
ulong isSet = 0;
if (IdleTimeoutMs is { } idle)
{
BinaryPrimitives.WriteUInt64LittleEndian(native.AsSpan(IdleTimeoutOffset), (ulong)idle);
isSet |= 1UL << IdleTimeoutFlag;
}
if (KeepAliveMs is { } keepAlive)
{
BinaryPrimitives.WriteUInt32LittleEndian(native.AsSpan(KeepAliveOffset), (uint)keepAlive);
isSet |= 1UL << KeepAliveFlag;
}
if (MinMtu is { } min)
{
BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MinMtuOffset), (ushort)min);
isSet |= 1UL << MinMtuFlag;
}
if (MaxMtu is { } max)
{
BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MaxMtuOffset), (ushort)max);
isSet |= 1UL << MaxMtuFlag;
}
if (Pacing is { } pacing)
{
native[BitFieldOffset] = (byte)(pacing ? PacingBit : 0);
isSet |= 1UL << PacingFlag;
}
BinaryPrimitives.WriteUInt64LittleEndian(native, isSet);
return native;
}
}
@@ -10,7 +10,7 @@ public static class Kinds
/// <summary>这个任务可能达到的最高探测频率(Hz):自适应时是 <c>rate_max_hz</c>,否则是固定的 <c>rate_hz</c>。预算、限速和准入都按它算。</summary>
public static long PeakRateHz(string kindId, ParamValues values) =>
(kindId is "udp_echo" or "tcp_echo") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
(kindId is "quic_ping" or "tcp_ping") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
private static readonly StreamSpec s_echoMsg = new(
"msg",
@@ -30,8 +30,8 @@ public static class Kinds
],
Union: true);
public static readonly KindSpec UdpEcho = new(
"udp_echo",
public static readonly KindSpec QuicPing = new(
"quic_ping",
"QUIC Datagram echo",
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
Scope.Line,
@@ -47,10 +47,10 @@ public static class Kinds
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
p => Bandwidth.UdpEcho(PeakRateHz("udp_echo", p), p.GetInt("size")));
p => Bandwidth.UdpEcho(PeakRateHz("quic_ping", p), p.GetInt("size")));
public static readonly KindSpec UdpTrain = new(
"udp_train",
public static readonly KindSpec QuicStream = new(
"quic_stream",
"QUIC Datagram train",
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to 拨号方) for loss and delay variation under load.",
Scope.Line,
@@ -68,8 +68,8 @@ public static class Kinds
],
p => (long)(p.GetNumber("mbps") * 1_000_000));
public static readonly KindSpec TcpEcho = new(
"tcp_echo",
public static readonly KindSpec TcpPing = new(
"tcp_ping",
"TCP echo",
"Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.",
Scope.Line,
@@ -88,10 +88,10 @@ public static class Kinds
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
p => Bandwidth.TcpEcho(PeakRateHz("tcp_echo", p), p.GetInt("max")));
p => Bandwidth.TcpEcho(PeakRateHz("tcp_ping", p), p.GetInt("max")));
public static readonly KindSpec TcpBulk = new(
"tcp_bulk",
public static readonly KindSpec TcpTransfer = new(
"tcp_transfer",
"TCP bulk",
"Bulk TCP transfer on several streams with periodic byte counters and tcp_info.",
Scope.Line,
@@ -110,8 +110,8 @@ public static class Kinds
],
_ => 0);
public static readonly KindSpec Connect = new(
"connect",
public static readonly KindSpec TcpConnect = new(
"tcp_connect",
"Connect",
"Repeated TCP and TLS connection establishment with per-phase timings and outcome.",
Scope.Line | Scope.Target,
@@ -129,8 +129,8 @@ public static class Kinds
],
p => p.GetInt("per_minute") * 4000 / 60);
public static readonly KindSpec Hold = new(
"hold",
public static readonly KindSpec TlsHold = new(
"tls_hold",
"Hold",
"A long-lived TLS connection with periodic heartbeats; records resets and lifetime.",
Scope.Line,
@@ -144,8 +144,38 @@ public static class Kinds
],
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
public static readonly KindSpec IcmpEcho = new(
"icmp_echo",
public static readonly KindSpec HttpRequest = new(
"http_request",
"HTTP request",
"One Agent requests a URL over HTTP/1.1, HTTP/2 or HTTP/3 and records DNS, TCP, time to first byte, total time and status. Target is the URL.",
Scope.Target,
new BlockSet("http", "fixed", "fixed", "request", []),
[
P.Enum("version", "h2", ["h1", "h2", "h3"], "HTTP version; the request fails instead of falling back."),
P.Enum("method", "GET", ["GET", "HEAD"]),
P.Bool("adaptive", true, "Adapt the request rate to the failure rate: low while the target answers, up to rate_max_per_min when it fails, exponential back-off while it is unreachable."),
P.Int("per_minute", 6, 1, 120, "/min", "Fixed rate, used when adaptive is false."),
P.Int("rate_min_per_min", 1, 1, 120, "/min"),
P.Int("rate_max_per_min", 12, 1, 120, "/min", "Highest rate, and the reference grid for sample weights."),
P.Number("tau_pct", 5, 0.1, 50, "%", "Failure rate a quiet target has to be able to prove below, at 95% confidence."),
P.Int("timeout_ms", 10000, 500, 60000, "ms"),
P.Int("read_kb", 16, 0, 1024, "KiB", "Body bytes to read before the request counts as finished (0: headers only)."),
P.Int("ok_status", 0, 0, 599, null, "Status that counts as success; 0 means any status below 400."),
P.Bool("tls_verify", true),
],
[
new StreamSpec(
"req",
[
Col.Seq("attempt"), Col.TsDialer("ts"), Col.U16("w"),
Col.U32("dns_us", nullable: true), Col.U32("tcp_us", nullable: true), Col.U32("ttfb_us", nullable: true), Col.U32("total_us", nullable: true),
Col.U16("status", nullable: true), Col.U8("proto", nullable: true), Col.U8("outcome"),
]),
],
p => p.GetInt("rate_max_per_min") * (2000 + p.GetInt("read_kb") * 1024) * 8 / 60);
public static readonly KindSpec IcmpPing = new(
"icmp_ping",
"ICMP echo",
"拨号方 or Agent side ICMP echo to a target address.",
Scope.Target,
@@ -159,7 +189,7 @@ public static class Kinds
],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly IReadOnlyList<KindSpec> All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho];
public static readonly IReadOnlyList<KindSpec> All = [QuicPing, QuicStream, TcpPing, TcpTransfer, TcpConnect, TlsHold, HttpRequest, IcmpPing];
public static KindSpec? Find(string id)
{
@@ -488,7 +488,7 @@ public sealed class ControlHealth
/// <summary>任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。</summary>
public sealed class TaskHealth
{
/// <summary>正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。</summary>
/// <summary>正在运行的 TCP 类线路积木数(tcp_ping、connect、hold、tcp_transfer)。</summary>
[JsonPropertyName("tcp_blocks")]
public int TcpBlocks { get; set; }
@@ -496,7 +496,7 @@ public sealed class TaskHealth
public QuicHealth Quic { get; set; } = new();
}
/// <summary>QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。</summary>
/// <summary>QUIC 数据通道(quic_ping、quic_stream 使用)的状态、握手统计和传输层统计。</summary>
public sealed class QuicHealth
{
[JsonPropertyName("state")]
@@ -0,0 +1,104 @@
using System.Globalization;
using System.Net;
namespace MoShiro.Protocol;
/// <summary>
/// 单个 Agent 任务的目标:<c>http</c> 是 <c>http(s)://主机[:端口][/路径]</c>,其余 Kind 是 <c>主机[:端口]</c>(IP 或域名,IPv6 带端口时用方括号)。
/// Backend 只检查格式,不做 DNS 解析;解析在 Agent 上进行,每个解析出的地址都要在 <c>allowed_target_cidrs</c> 里。
/// </summary>
public static class TargetHost
{
public const string HttpKind = "http_request";
/// <summary>解析目标;<paramref name="port"/> 为 0 表示没有写端口,<paramref name="url"/> 只在 http(s) URL 时有值。</summary>
public static bool TryParse(string? target, out string host, out int port, out Uri? url)
{
host = "";
port = 0;
url = null;
if (string.IsNullOrWhiteSpace(target) || target.Length > 2048 || target.Any(char.IsWhiteSpace))
{
return false;
}
if (target.Contains("://", StringComparison.Ordinal))
{
if (!Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) || uri.Scheme is not ("http" or "https") || uri.UserInfo.Length > 0 || !ValidHost(uri.IdnHost))
{
return false;
}
url = uri;
host = uri.IdnHost.Trim('[', ']');
port = uri.IsDefaultPort ? 0 : uri.Port;
return true;
}
// IPAddress.TryParse 会把「[v6]:端口」的端口丢掉,带方括号的先按端点解析
if (!target.StartsWith('[') && IPAddress.TryParse(target, out IPAddress? ip))
{
host = ip.ToString();
return true;
}
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
if (endpoint.Port is 0)
{
return false;
}
host = endpoint.Address.ToString();
port = endpoint.Port;
return true;
}
int colon = target.LastIndexOf(':');
string name = colon < 0 ? target : target[..colon];
if (colon >= 0 && (!int.TryParse(target.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535))
{
return false;
}
if (!ValidHost(name))
{
return false;
}
host = name.ToLowerInvariant();
return true;
}
/// <summary>按 Kind 检查并规范化:<c>http</c> 必须是 http(s) URL,其余 Kind 必须是主机[:端口];不合格返回 null。</summary>
public static string? Normalize(string kindId, string? target)
{
if (!TryParse(target, out string host, out int port, out Uri? url) || (url is not null) != (kindId == HttpKind))
{
return null;
}
if (url is not null)
{
return url.AbsoluteUri;
}
string shown = IPAddress.TryParse(host, out IPAddress? ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && port != 0 ? "[" + host + "]" : host;
return port == 0 ? host : shown + ":" + port.ToString(CultureInfo.InvariantCulture);
}
private static bool ValidHost(string name)
{
if (IPAddress.TryParse(name.Trim('[', ']'), out _))
{
return true;
}
if (name.Length is 0 or > 253 || name[^1] == '-' || name[0] is '-' or '.')
{
return false;
}
return name.TrimEnd('.').Split('.').All(label => label.Length is > 0 and <= 63 && label.All(c => char.IsAsciiLetterOrDigit(c) || c == '-'));
}
}
@@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte
EchoRequest = 1,
EchoReply = 2,
/// <summary>单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
/// <summary>单向数据(quic_stream),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
Data = 3,
}
@@ -1,3 +1,4 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
@@ -24,11 +25,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard();
double mbps = 0;
long pps = 0;
Assert.True(guard.Check(Line("udp_echo"), ref mbps, ref pps).Accepted);
Assert.True(guard.Check(Line("quic_ping"), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.Unsupported, guard.Check(Line("no_such_kind"), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("nope", "1")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("udp_echo", ("port", "9999")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("nope", "1")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("quic_ping", ("port", "9999")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -37,11 +38,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
double mbps = 0;
long pps = 0;
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("20") }).Reason);
Assert.True(guard.CheckPhase("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = Json("20") }).Reason);
Assert.True(guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Reason);
Assert.True(guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -54,8 +55,15 @@ public sealed class MeasurementTests
Assert.True(some.TargetAllowed("192.0.2.17:443"));
Assert.True(some.TargetAllowed("2001:db8::1"));
Assert.False(some.TargetAllowed("192.0.3.1"));
Assert.False(some.TargetAllowed("example.com"));
Assert.False(some.TargetAllowed(null));
// 域名要解析之后才知道地址:配置过白名单就先接受,每个解析出的地址再由 AddressAllowed 检查
Assert.True(some.TargetAllowed("example.com:443"));
Assert.True(some.TargetAllowed("https://example.com/health"));
Assert.False(Guard().TargetAllowed("example.com"));
Assert.True(some.AddressAllowed(IPAddress.Parse("192.0.2.9")));
Assert.False(some.AddressAllowed(IPAddress.Parse("10.0.0.1")));
Assert.False(some.AddressAllowed(IPAddress.Parse("::ffff:10.0.0.1")));
}
// ---- RowStore ----
@@ -63,7 +71,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder()
{
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
StreamSpec msg = Kinds.QuicPing.FindStream("msg")!;
var dialer = new RowStore(msg, Side.Dialer, 64);
Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
@@ -88,7 +96,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne()
{
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16);
var store = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 16);
store.SetRow(3, [30, 31]);
Assert.True(store.TryGet(3, 0, out long t2));
Assert.Equal(30, t2);
@@ -117,19 +125,19 @@ public sealed class MeasurementTests
msg.Add(seq, 64, 1, t1, t4);
}
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
return new RawChunk(new RawChunkHeader { Kind = "quic_ping", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
}
[Fact]
public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
agent.SetRow(2, [4000, 4040]);
var source = new Source { Stores = { ["msg"] = agent } };
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null), (2, 3000, 5000));
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, source);
RawStream msg = Assert.Single(merged.Value);
Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
@@ -141,7 +149,7 @@ public sealed class MeasurementTests
public void FragmentMerger_DialerDriven_WithoutAnyAgentRecords_KeepsTheDialerRows()
{
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null));
RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value);
RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).Value);
Assert.Equal(2, msg.Count);
Assert.False(msg.Find("t2")!.IsValid(0));
Assert.Equal(3000, msg.Find("t4")!.Values[0]);
@@ -152,14 +160,14 @@ public sealed class MeasurementTests
{
var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")]));
bad.Add(0);
var fragment = new RawChunk(new RawChunkHeader { Kind = "udp_echo" }, [bad.Build()]);
Assert.False(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).IsSuccess);
var fragment = new RawChunk(new RawChunkHeader { Kind = "quic_ping" }, [bad.Build()]);
Assert.False(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).IsSuccess);
}
[Fact]
public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost()
{
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicStream.FindStream("pkt")!, Side.Listener, 64);
for (uint seq = 0; seq < 5; seq++)
{
agent.SetRow(seq, [1000, 100 + seq]);
@@ -167,12 +175,12 @@ public sealed class MeasurementTests
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv")]));
pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost
var header = new RawChunkHeader { Kind = "udp_train" };
var header = new RawChunkHeader { Kind = "quic_stream" };
header.Params[FragmentKeys.SeqFrom] = Json("0");
header.Params[FragmentKeys.SeqTo] = Json("5");
var source = new Source { Stores = { ["pkt"] = agent } };
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(header, [pkt.Build()]), 1, source).Value);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(header, [pkt.Build()]), 1, source).Value);
Assert.Equal(5, merged.Count);
Assert.Equal(["seq", "size", "t_send", "t_recv"], merged.Columns.Select(c => c.Name));
RawColumn recv = merged.Find("t_recv")!;
@@ -181,7 +189,7 @@ public sealed class MeasurementTests
Assert.Equal(103, merged.Find("t_send")!.Values[3]);
// A one-way fragment that does not declare its sequence range is invalid
Assert.False(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(new RawChunkHeader { Kind = "udp_train" }, [pkt.Build()]), 1, source).IsSuccess);
Assert.False(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(new RawChunkHeader { Kind = "quic_stream" }, [pkt.Build()]), 1, source).IsSuccess);
}
[Fact]
@@ -189,9 +197,9 @@ public sealed class MeasurementTests
{
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsDialer("ts")]));
conn.Add(0, 0, 100);
var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]);
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_connect" }, [conn.Build()]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.Connect, fragment, 1, new Source()).Value);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpConnect, fragment, 1, new Source()).Value);
Assert.Equal(["attempt", "ts", "tcp_us", "tls_us", "outcome"], merged.Columns.Select(c => c.Name));
Assert.False(merged.Find("tcp_us")!.IsValid(0));
}
@@ -212,7 +220,7 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Listener).Count); // each side owns every column
var agent = new RowStore(info, Side.Listener, 64);
var recorder = new Source { Stores = { ["tcp_info"] = agent } };
@@ -222,7 +230,7 @@ public sealed class MeasurementTests
var msg = EchoFragment((0, 1000, 3000));
var fragment = new RawChunk(msg.Header, [msg.Streams[0], InfoRows(info, (100, 0, 1))]);
IReadOnlyList<RawStream> first = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
IReadOnlyList<RawStream> first = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
RawStream merged = first.Single(x => x.Name == "tcp_info");
Assert.Equal(3, merged.Count);
Assert.Equal([0L, 1L, 1L], merged.Find("side")!.Values);
@@ -231,20 +239,20 @@ public sealed class MeasurementTests
Assert.Equal(1, merged.Find("conn")!.Values[1]);
// The agent's rows were consumed: the next chunk carries only the dialer's own rows
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count);
}
[Fact]
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
var agent = new RowStore(info, Side.Listener, 64);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
// A dialer on a platform without kernel sampling sends the echo rows only
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
IReadOnlyList<RawStream> merged = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
RawStream rows = merged.Single(x => x.Name == "tcp_info");
Assert.Equal(1, rows.Count);
Assert.Equal(7, rows.Find("retrans_total")!.Values[0]);
@@ -253,11 +261,11 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_LegacyTcpSamples_KeepConnectionIdentityUnknown()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
RawStream sample = InfoRows(info, (100, 0, 1));
var legacy = new RawStream(sample.Name, sample.Count, sample.Columns.Where(c => c.Name != "conn").ToArray());
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_echo" }, [legacy]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source()).Value);
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_ping" }, [legacy]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source()).Value);
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("retrans_total")!.Values[0]);
}
@@ -282,7 +290,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Tcp_WaitsForTheReplyInsteadOfCallingASeveralSecondStallALoss()
{
var rows = new EchoRows(Kinds.TcpEcho, 64, EchoRows.TcpGraceUs, settleOnReply: true);
var rows = new EchoRows(Kinds.TcpPing, 64, EchoRows.TcpGraceUs, settleOnReply: true);
uint a = rows.Add(64, 0);
uint b = rows.Add(64, 100_000);
uint c = rows.Add(64, 200_000);
@@ -312,7 +320,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
{
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
rows.Add(64, 0);
rows.Add(64, 1_000_000);
RawStream cut = rows.Take(2_500_000, all: false)!;
@@ -324,7 +332,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_DiscardedLocalSendIsNotReportedAsNetworkLoss()
{
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
uint unsent = rows.Add(64, 0);
Assert.True(rows.Discard(unsent));
@@ -369,7 +377,7 @@ public sealed class MeasurementTests
public void BlockSpec_ConfigSignatureIgnoresTheStreamId_ButSignatureDoesNot()
{
BlockSpec Create(string json, uint stream = 1, int port = 7443) => BlockSpec.Create(
"udp_echo",
"quic_ping",
JsonDocument.Parse(json).RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()),
stream,
port,
@@ -381,7 +389,7 @@ public sealed class MeasurementTests
Assert.NotEqual(baseline.Signature, Create("""{"rate_hz":10,"size":64}""", stream: 2).Signature);
Assert.Equal(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", stream: 2).ConfigSignature);
Assert.NotEqual(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", port: 9000).ConfigSignature);
Assert.Null(BlockSpec.Create("udp_echo", new Dictionary<string, JsonElement> { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
Assert.Null(BlockSpec.Create("quic_ping", new Dictionary<string, JsonElement> { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
}
// ---- 起止时间 ----
@@ -389,13 +397,13 @@ public sealed class MeasurementTests
[Fact]
public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
IReadOnlyList<RawStream> streams = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
var anchor = new ClockAnchor(ListenerMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary<string, JsonElement> { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
RawChunk chunk = ChunkFinalizer.Finalize(Kinds.QuicPing, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary<string, JsonElement> { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
// t1 = 1000 us (拨号方 clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs);
@@ -0,0 +1,57 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement.Tests;
public sealed class QuicSettingsTests
{
[Theory]
[InlineData(1100, null, false)]
[InlineData(null, 70_000, false)]
[InlineData(1400, 1300, false)]
[InlineData(1280, 1500, true)]
[InlineData(null, null, true)]
public void Validate_RejectsMtuOutsideTheSupportedRangeOrOutOfOrder(int? min, int? max, bool valid) =>
Assert.Equal(valid, new QuicSettings { MinMtu = min, MaxMtu = max }.Validate() is null);
/// <summary>设置真的写进了 MsQuic:最大 MTU 压低后数据报上限随之变小,最小 MTU 抬高后一连上就是大包(不用等路径探测)。</summary>
[Theory]
[InlineData(1200, 1280, 1200, 1280)]
[InlineData(1500, 1500, 1400, 1500)]
public async Task Settings_ChangeTheDatagramLimitOfTheConnection(int min, int max, int atLeast, int atMost)
{
var settings = new QuicSettings { MinMtu = min, MaxMtu = max };
var log = new NullLog();
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate, (_, _) => { }, log, null, settings);
listener.Start();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0, key.ExportSubjectPublicKeyInfo(), (_, _) => { }, log, timeout.Token, settings);
try
{
while (!client.IsReady)
{
await Task.Delay(10, timeout.Token);
}
// 路径 MTU 探测还可能在往上走,给它一点时间;上限始终不能超过设置的最大 MTU
await Task.Delay(1500, timeout.Token);
Assert.InRange(client.MaxDatagramBytes, atLeast - 100, atMost);
}
finally
{
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3));
}
}
private sealed class NullLog : INodeLog
{
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
}
}
}
@@ -35,7 +35,7 @@ public sealed class CatalogTests
public void DefaultLineTemplates_StayWithinTheLineBudget()
{
long total = 0;
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
foreach (KindSpec kind in new[] { Kinds.QuicPing, Kinds.TcpPing })
{
total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!);
}
@@ -52,7 +52,7 @@ public sealed class CatalogTests
["size"] = Json("\"big\""),
["bogus"] = Json("1"),
};
ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input);
ParamResolution result = ParamResolver.Resolve(Kinds.QuicPing.Params, input);
Assert.False(result.IsValid);
Assert.Contains("between 1 and 200", result.Errors["rate_hz"]);
@@ -64,13 +64,13 @@ public sealed class CatalogTests
[Fact]
public void Resolve_AcceptsBoundariesAndRejectsJustOutside()
{
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicStream.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.TcpPing.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
}
[Fact]
@@ -98,7 +98,7 @@ public sealed class CatalogTests
var set = new TaskSet
{
Version = 7,
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "quic_ping", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
@@ -114,9 +114,9 @@ public sealed class CatalogTests
[Fact]
public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"quic_ping\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听
Assert.Equal(["udp_echo"], meta.Kinds);
Assert.Equal(["quic_ping"], meta.Kinds);
}
[Fact]
@@ -28,7 +28,7 @@ public sealed class RawChunkCodecTests
private static RawChunk SampleChunk()
{
RawChunk chunk = ChunkFactory.Echo(
s_agent, "udp_echo", 1_700_000_000_000, 6, 100,
s_agent, "quic_ping", 1_700_000_000_000, 6, 100,
i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000));
chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001"));
return chunk;
@@ -41,7 +41,7 @@ public sealed class RawChunkCodecTests
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value;
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
Assert.Equal("udp_echo", decoded.Header.Kind);
Assert.Equal("quic_ping", decoded.Header.Kind);
Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs);
RawStream before = original.Find("msg")!;
RawStream after = decoded.Find("msg")!;
@@ -100,7 +100,7 @@ public sealed class RawChunkCodecTests
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength));
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header);
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"quic_ping\"", header);
Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header);
Assert.Equal(GoldenColumns, columns);
}
@@ -181,7 +181,7 @@ public sealed class RawChunkCodecTests
[Fact]
public void Encode_IsSmallerThanTheNaiveRepresentation()
{
// 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节
// 10 Hz 的 quic_ping 一个 10 秒块应当远小于每行 6×8 字节
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000);
Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4);
}
@@ -0,0 +1,28 @@
using MoShiro.Protocol;
namespace MoShiro.Protocol.Tests;
public sealed class TargetHostTests
{
[Theory]
[InlineData("http_request", "https://Example.com/health?x=1", "https://example.com/health?x=1")]
[InlineData("http_request", "http://192.0.2.1:8080", "http://192.0.2.1:8080/")]
[InlineData("tcp_connect", "Example.com:443", "example.com:443")]
[InlineData("tcp_connect", "192.0.2.1", "192.0.2.1")]
[InlineData("tcp_connect", "[2001:db8::1]:443", "[2001:db8::1]:443")]
[InlineData("icmp_ping", "2001:db8::1", "2001:db8::1")]
public void Normalize_AcceptsTheTargetFormOfTheKind(string kind, string target, string expected) =>
Assert.Equal(expected, TargetHost.Normalize(kind, target));
[Theory]
[InlineData("http_request", "example.com")] // http 必须是 URL
[InlineData("http_request", "ftp://example.com/")]
[InlineData("http_request", "https://user:pw@example.com/")]
[InlineData("tcp_connect", "https://example.com/")] // 其余 Kind 不能是 URL
[InlineData("tcp_connect", "example.com:0")]
[InlineData("tcp_connect", "exa mple.com")]
[InlineData("tcp_connect", "-bad.example.com")]
[InlineData("tcp_connect", "")]
public void Normalize_RejectsTheWrongFormForTheKind(string kind, string target) =>
Assert.Null(TargetHost.Normalize(kind, target));
}
@@ -31,7 +31,7 @@ public sealed class TestResult
/// <summary>
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
/// quic_stream 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
/// </summary>
public static class TestEvaluator
{
@@ -43,7 +43,7 @@ public static class TestEvaluator
{
var result = new TestResult();
EvaluateMetrics(chunks, result);
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
if (chunks.Any(c => c.Header.Kind == Kinds.QuicStream.Id))
{
EvaluateLadder(chunks, result);
}
@@ -95,7 +95,7 @@ public static class TestEvaluator
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块):
// 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。
foreach (IGrouping<int, RawChunk> phase in chunks.Where(c => c.Header.Kind == Kinds.UdpTrain.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
foreach (IGrouping<int, RawChunk> phase in chunks.Where(c => c.Header.Kind == Kinds.QuicStream.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
{
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
if (parts.Length == 0)
@@ -0,0 +1,48 @@
namespace MoShiro.Backend.Analysis;
/// <summary>http 任务(单个 Agent 请求一个 URL)的指标。耗时只统计成功的请求。</summary>
public static class HttpMetrics
{
private static readonly string[] s_req = ["http_request.req"];
public static readonly MetricDef Success = new(
"http.success", AccKind.Ratio, s_req, Ops.HttpSuccess, "HTTP success", "Share of requests that got an answer with an accepted status.", Better.Higher,
[new MetricExport("http.success", Stat.Pct, Unit.Pct, 99, 90)]);
public static readonly MetricDef Dns = Metric.Histogram(
"http.dns_ms", s_req, Ops.HttpPhaseMs("dns_us"), "DNS time", "Time to resolve the target host name.", Unit.Ms, [Stat.P95], good: 50, poor: 300);
public static readonly MetricDef Tcp = Metric.Histogram(
"http.tcp_ms", s_req, Ops.HttpPhaseMs("tcp_us"), "TCP connect time", "Time to establish the TCP connection (HTTP/1.1 and HTTP/2).", Unit.Ms, [Stat.P95], good: 100, poor: 400);
public static readonly MetricDef Ttfb = Metric.Histogram(
"http.ttfb_ms", s_req, Ops.HttpPhaseMs("ttfb_us"), "Time to first byte", "Time from starting the request until the response headers arrive, including connection setup.", Unit.Ms,
[Stat.P50, Stat.P95, Stat.P99], good: 300, poor: 1000);
public static readonly MetricDef Total = Metric.Histogram(
"http.total_ms", s_req, Ops.HttpPhaseMs("total_us"), "Total time", "Time until the response body was read.", Unit.Ms, [Stat.P50, Stat.P95, Stat.P99], good: 500, poor: 2000);
public static readonly MetricDef FailTimeout = Metric.Counter(
"http.fail.timeout", s_req, Ops.HttpOutcomeCount(HttpOutcome.Timeout), "HTTP timeouts", "Requests that timed out.", new MetricExport("http.fail.timeout", Stat.Count, Unit.Count));
public static readonly MetricDef FailDns = Metric.Counter(
"http.fail.dns", s_req, Ops.HttpOutcomeCount(HttpOutcome.Dns), "DNS failures", "Requests whose host name did not resolve.", new MetricExport("http.fail.dns", Stat.Count, Unit.Count));
public static readonly MetricDef FailReset = Metric.Counter(
"http.fail.rst", s_req, Ops.HttpOutcomeCount(HttpOutcome.Reset), "HTTP resets", "Requests answered with a reset or refused.", new MetricExport("http.fail.rst", Stat.Count, Unit.Count));
public static readonly MetricDef FailTls = Metric.Counter(
"http.fail.tls", s_req, Ops.HttpOutcomeCount(HttpOutcome.Tls), "TLS failures", "Requests that ended in a TLS error.", new MetricExport("http.fail.tls", Stat.Count, Unit.Count));
public static readonly MetricDef FailStatus = Metric.Counter(
"http.fail.status", s_req, Ops.HttpOutcomeCount(HttpOutcome.BadStatus), "Bad status", "Requests that got an answer with an unaccepted status.", new MetricExport("http.fail.status", Stat.Count, Unit.Count));
public static readonly MetricDef FailUnsupported = Metric.Counter(
"http.fail.unsupported", s_req, Ops.HttpOutcomeCount(HttpOutcome.Unsupported), "Unsupported version", "Requests that could not use the requested HTTP version (for HTTP/3: no QUIC library, or the server does not offer it).", new MetricExport("http.fail.unsupported", Stat.Count, Unit.Count));
public static readonly MetricDef FailOther = Metric.Counter(
"http.fail.other", s_req, Ops.HttpOutcomeCount(HttpOutcome.Other), "Other failures", "Requests that failed for a reason not listed separately.", new MetricExport("http.fail.other", Stat.Count, Unit.Count));
public static readonly MetricDef Samples = Metric.Counter(
"n.http", s_req, Ops.RowCount, "http samples", "Number of http requests.", new MetricExport("n.http", Stat.Count, Unit.Count), Better.None);
}
@@ -1,10 +1,10 @@
namespace MoShiro.Backend.Analysis;
/// <summary>线路基础指标(udp_echo / icmp_echo 的回显流)。新增指标 = 加一行声明。</summary>
/// <summary>线路基础指标(quic_ping / icmp_ping 的回显流)。新增指标 = 加一行声明。</summary>
public static class LinkMetrics
{
private static readonly string[] s_udpMsg = ["udp_echo.msg"];
private static readonly string[] s_udpOrIcmpMsg = ["udp_echo.msg", "icmp_echo.msg"];
private static readonly string[] s_udpMsg = ["quic_ping.msg"];
private static readonly string[] s_udpOrIcmpMsg = ["quic_ping.msg", "icmp_ping.msg"];
public static readonly MetricDef Rtt = Metric.Histogram(
"rtt", s_udpOrIcmpMsg, Ops.Rtt, "RTT", "Round-trip time with the Agent processing time removed.", Unit.Ms,
@@ -45,5 +45,5 @@ public static class LinkMetrics
"dup", s_udpMsg, Ops.Dup, "Duplicates", "Replies received more than once.", new MetricExport("dup", Stat.Count, Unit.Count));
public static readonly MetricDef SamplesUdp = Metric.Counter(
"n.udp_echo", s_udpMsg, Ops.RowCount, "udp_echo samples", "Number of udp_echo records.", new MetricExport("n.udp_echo", Stat.Count, Unit.Count), Better.None);
"n.quic_ping", s_udpMsg, Ops.RowCount, "quic_ping samples", "Number of quic_ping records.", new MetricExport("n.quic_ping", Stat.Count, Unit.Count), Better.None);
}
@@ -15,6 +15,8 @@ public static class MetricRegistry
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.RetransUp, TcpMetrics.RetransDown, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
HttpMetrics.Success, HttpMetrics.Dns, HttpMetrics.Tcp, HttpMetrics.Ttfb, HttpMetrics.Total,
HttpMetrics.FailTimeout, HttpMetrics.FailDns, HttpMetrics.FailReset, HttpMetrics.FailTls, HttpMetrics.FailStatus, HttpMetrics.FailUnsupported, HttpMetrics.FailOther, HttpMetrics.Samples,
SessionMetrics.HoldResets, SessionMetrics.HoldLifetime,
SessionMetrics.TrainLoss, SessionMetrics.TrainJitter, SessionMetrics.TrainRate, SessionMetrics.SamplesConnect,
];
@@ -26,6 +28,7 @@ public static class MetricRegistry
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.retrans.up", "tcp.retrans.down", "tcp.srtt.p95"]),
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
new("http", "HTTP", ["http.success", "http.dns_ms.p95", "http.tcp_ms.p95", "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.fail.timeout", "http.fail.dns", "http.fail.rst", "http.fail.tls", "http.fail.status", "http.fail.unsupported", "http.fail.other"]),
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]),
];
@@ -1,11 +1,11 @@
namespace MoShiro.Backend.Analysis;
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind quic_stream)的指标。</summary>
public static class SessionMetrics
{
private static readonly string[] s_conn = ["connect.conn"];
private static readonly string[] s_hold = ["hold.hold"];
private static readonly string[] s_train = ["udp_train.pkt"];
private static readonly string[] s_conn = ["tcp_connect.conn"];
private static readonly string[] s_hold = ["tls_hold.hold"];
private static readonly string[] s_train = ["quic_stream.pkt"];
public static readonly MetricDef ConnectSuccess = new(
"connect.success", AccKind.Ratio, s_conn, Ops.OutcomeRatio(ConnectOutcome.Ok), "Connect success", "Share of connection attempts that completed.", Better.Higher,
@@ -3,9 +3,9 @@ namespace MoShiro.Backend.Analysis;
/// <summary>TCP 指标:回显的 RTT 与卡顿、内核采样的重传、批量传输的吞吐。</summary>
public static class TcpMetrics
{
private static readonly string[] s_tcpMsg = ["tcp_echo.msg"];
private static readonly string[] s_tcpInfo = ["tcp_echo.tcp_info", "tcp_bulk.tcp_info"];
private static readonly string[] s_bulkBytes = ["tcp_bulk.bytes"];
private static readonly string[] s_tcpMsg = ["tcp_ping.msg"];
private static readonly string[] s_tcpInfo = ["tcp_ping.tcp_info", "tcp_transfer.tcp_info"];
private static readonly string[] s_bulkBytes = ["tcp_transfer.bytes"];
public static readonly MetricDef Rtt = Metric.Histogram(
"tcp.rtt", s_tcpMsg, Ops.Rtt, "TCP RTT", "Application-level echo round trip over TCP (includes retransmission delay).", Unit.Ms,
@@ -47,5 +47,5 @@ public static class TcpMetrics
[Stat.Max], Better.Higher);
public static readonly MetricDef SamplesTcp = Metric.Counter(
"n.tcp_echo", s_tcpMsg, Ops.RowCount, "tcp_echo samples", "Number of tcp_echo records.", new MetricExport("n.tcp_echo", Stat.Count, Unit.Count), Better.None);
"n.tcp_ping", s_tcpMsg, Ops.RowCount, "tcp_ping samples", "Number of tcp_ping records.", new MetricExport("n.tcp_ping", Stat.Count, Unit.Count), Better.None);
}
@@ -32,7 +32,7 @@ public sealed class BaseModel : ServiceModelBase
/// <summary>
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
/// 没有 tcp_ping 数据时,用 quic_ping 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
/// </summary>
public sealed class McModel : ServiceModelBase
{
@@ -99,7 +99,7 @@ public sealed class McModel : ServiceModelBase
/// <summary>
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
/// 没有测试时,只能用持续的 quic_ping 趋势粗略估算,并标记为 estimated。
/// </summary>
public sealed class Hy2Model : ServiceModelBase
{
@@ -0,0 +1,119 @@
using MoShiro.Protocol;
namespace MoShiro.Backend.Analysis;
/// <summary>http 请求的结果,编码与 Agent 的 <c>HttpOutcomes</c> 一致。</summary>
public enum HttpOutcome
{
Ok = 0,
Timeout = 1,
Reset = 2,
Tls = 3,
Other = 4,
Dns = 5,
BadStatus = 6,
Unsupported = 7,
NotAllowed = 8,
}
/// <summary>http 流(req)的公共读法:权重 <c>w</c> 是这一行代表的网格间隔数(自适应降频后每次请求代表更长的时间)。</summary>
internal readonly struct HttpColumns
{
public HttpColumns(RawStream stream)
{
Stream = stream;
Ts = stream.Find("ts");
Outcome = stream.Find("outcome");
Weights = stream.Find("w");
}
public RawStream Stream { get; }
public RawColumn? Ts { get; }
public RawColumn? Outcome { get; }
private RawColumn? Weights { get; }
public bool Has => Ts is not null && Outcome is not null;
public int Weight(int row) => Weights is not null && Weights.IsValid(row) ? (int)Math.Clamp(Weights.Values[row], 1, 65535) : 1;
}
/// <summary>成功率(分子是按权重累计的成功次数)或某种结果的次数。</summary>
internal sealed class HttpOutcomeOp : MetricOp
{
private readonly HttpOutcome _code;
private readonly bool _asRatio;
public HttpOutcomeOp(HttpOutcome code, bool asRatio)
{
_code = code;
_asRatio = asRatio;
}
public override string Signature => $"http_outcome/{_code}/{(_asRatio ? "ratio" : "count")}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new HttpColumns(stream);
if (!c.Has)
{
return;
}
for (int i = 0; i < stream.Count; i++)
{
bool hit = c.Outcome!.Values[i] == (long)_code;
long at = view.ColumnToUtcMs(c.Ts!, c.Ts!.Values[i]);
if (_asRatio)
{
int w = c.Weight(i);
sink.Emit(at, hit ? w : 0, w);
}
else if (hit)
{
sink.Emit(at, 1);
}
}
}
}
/// <summary>一个阶段的耗时(毫秒):只统计成功的请求,按权重累计,所以分位数是按时间平均的。</summary>
internal sealed class HttpPhaseMsOp : MetricOp
{
private const int MaxRepeat = 256;
private readonly string _column;
public HttpPhaseMsOp(string column)
{
_column = column;
}
public override string Signature => $"http_phase_ms/{_column}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new HttpColumns(stream);
RawColumn? column = stream.Find(_column);
if (!c.Has || column is null)
{
return;
}
for (int i = 0; i < stream.Count; i++)
{
if (c.Outcome!.Values[i] != (long)HttpOutcome.Ok || !column.IsValid(i))
{
continue;
}
long at = view.ColumnToUtcMs(c.Ts!, c.Ts!.Values[i]);
for (int k = Math.Min(c.Weight(i), MaxRepeat); k > 0; k--)
{
sink.Emit(at, column.Values[i] / 1000.0);
}
}
}
}
@@ -37,6 +37,12 @@ public static class Ops
public static MetricOp OutcomeCount(ConnectOutcome code) => new OutcomeOp(code, asRatio: false);
public static MetricOp HttpSuccess { get; } = new HttpOutcomeOp(HttpOutcome.Ok, asRatio: true);
public static MetricOp HttpOutcomeCount(HttpOutcome code) => new HttpOutcomeOp(code, asRatio: false);
public static MetricOp HttpPhaseMs(string column) => new HttpPhaseMsOp(column);
public static MetricOp HoldEvents(HoldEvent holdEvent) => new HoldEventOp(holdEvent);
public static MetricOp HoldLifetime { get; } = new HoldLifetimeOp();
@@ -330,7 +330,7 @@ internal sealed class HoldLifetimeOp : MetricOp
}
}
/// <summary>单向 UDP 流(udp_train)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
/// <summary>单向 UDP 流(quic_stream)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
internal sealed class TrainLossOp : MetricOp
{
public override string Signature => "train_loss/1";
@@ -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;
}
}
}
@@ -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:&lt;任务 id&gt;</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];
@@ -3,12 +3,12 @@ using Microsoft.Data.Sqlite;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>
/// 表结构。版本记录在 <c>PRAGMA user_version</c>。当前只有一个版本:不是这个版本的数据库(包括更早的构建留下的)不会被迁移,
/// 启动时报错,删除 <c>moshiro.db</c> 和 <c>raw/</c> 之后重新开始。以后的结构变更按版本号追加内置 SQL。
/// 表结构。版本记录在 <c>PRAGMA user_version</c>。版本 1 到 2 只改了 Kind 的名字(见 <see cref="RenameKinds"/>);
/// 其他版本的数据库(包括更早的构建留下的)不会被迁移,启动时报错,删除 <c>moshiro.db</c> 和 <c>raw/</c> 之后重新开始。以后的结构变更按版本号追加内置 SQL。
/// </summary>
internal static class Schema
{
public const int CurrentVersion = 1;
public const int CurrentVersion = 2;
public static void Migrate(SqliteConnection connection)
{
@@ -18,6 +18,21 @@ internal static class Schema
return;
}
if (version == 1)
{
using SqliteTransaction upgrade = connection.BeginTransaction();
foreach ((string from, string to) in RenameKinds)
{
connection.Exec($"UPDATE tasks SET kind = '{to}' WHERE kind = '{from}'");
connection.Exec($"UPDATE raw_chunks SET kind = '{to}' WHERE kind = '{from}'");
connection.Exec($"UPDATE tests SET plan = REPLACE(plan, '\"{from}\"', '\"{to}\"')");
}
connection.Exec("PRAGMA user_version = 2");
upgrade.Commit();
return;
}
if (version != 0)
{
throw new InvalidOperationException($"database schema version {version} is not supported by this build (expected {CurrentVersion}); delete moshiro.db and the raw/ directory to start over");
@@ -25,10 +40,17 @@ internal static class Schema
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec(V1);
connection.Exec("PRAGMA user_version = 1");
connection.Exec("PRAGMA user_version = 2");
transaction.Commit();
}
/// <summary>版本 2:Kind 改成「协议 + 做什么」的名字。任务和原始数据索引跟着改;已经落盘的原始块头里还是旧名字,按旧名字解不出指标,重算不到它们,已经算好的指标不受影响。</summary>
private static readonly (string From, string To)[] RenameKinds =
[
("udp_echo", "quic_ping"), ("tcp_echo", "tcp_ping"), ("udp_train", "quic_stream"), ("tcp_bulk", "tcp_transfer"),
("connect", "tcp_connect"), ("hold", "tls_hold"), ("icmp_echo", "icmp_ping"),
];
/// <summary>
/// 新增 Kind 或指标都不需要改表结构:指标是 <c>metric_*.metrics</c> 里的一个键,Kind 只是 <c>raw_chunks.kind</c> 的一个取值。
/// 节点只有一张表(<c>agents</c>):监听的节点有地址和端口,不监听的节点只拨出。线路 <c>(agent_id, series)</c> 里的 agent_id 是监听方,series 里是拨号方。
@@ -32,8 +32,8 @@ public sealed class ModelAndTestEvaluatorTests
[Fact]
public void Fingerprint_ChangesWhenTheOpChanges()
{
var a = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
var b = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 200), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
var a = Metric.Counter("x", ["quic_ping.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
var b = Metric.Counter("x", ["quic_ping.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 200), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
Assert.NotEqual(a.Fingerprint, b.Fingerprint);
}
@@ -25,7 +25,7 @@ public sealed class OpTests
[Fact]
public void Rtt_RemovesAgentProcessingTime()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, _ => new EchoPacket(false, false, 20_000, 30_000, ProcessUs: 5_000));
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, 100, 100, _ => new EchoPacket(false, false, 20_000, 30_000, ProcessUs: 5_000));
var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "rtt").Items["rtt"];
Assert.Equal(100UL, rtt.Count);
Assert.InRange(rtt.Quantile(0.5)!.Value, 50 * 0.955, 50 * 1.045);
@@ -37,8 +37,8 @@ public sealed class OpTests
static EchoPacket At(int i) => new(LostUp: i % 20 < 2, LostDown: false, i % 10 < 2 ? 100_000 : 20_000, 20_000);
// 每 100 ms 一个包,每个权重 1;对照:只取偶数行,每 200 ms 一个包,每个权重 2——代表同样长的时间
RawChunk dense = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, At);
RawChunk sparse = ChunkFactory.Echo(s_agent, "udp_echo", T0, 50, 200, i => At(i * 2), weight: _ => 2);
RawChunk dense = ChunkFactory.Echo(s_agent, "quic_ping", T0, 100, 100, At);
RawChunk sparse = ChunkFactory.Echo(s_agent, "quic_ping", T0, 50, 200, i => At(i * 2), weight: _ => 2);
MetricSet d = EvaluateOneBucket(dense, "rtt", "loss.up", "loss.total");
MetricSet s = EvaluateOneBucket(sparse, "rtt", "loss.up", "loss.total");
@@ -54,7 +54,7 @@ public sealed class OpTests
public void Loss_SeparatesUpAndDown()
{
RawChunk chunk = ChunkFactory.Echo(
s_agent, "udp_echo", T0, 100, 100,
s_agent, "quic_ping", T0, 100, 100,
i => new EchoPacket(LostUp: i < 10, LostDown: i is >= 10 and < 15, 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "loss.up", "loss.down", "loss.total");
@@ -77,7 +77,7 @@ public sealed class OpTests
public void Jitter_OnlyOneDirectionVaries_IsAttributedToThatDirection()
{
// 下行时延每隔一个包多 20 ms
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, i => new EchoPacket(false, false, 20_000, 20_000 + (i % 2 == 0 ? 20_000 : 0)));
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, 100, 100, i => new EchoPacket(false, false, 20_000, 20_000 + (i % 2 == 0 ? 20_000 : 0)));
MetricSet set = EvaluateOneBucket(chunk, "jitter.up", "jitter.down");
Assert.True(((HistogramAcc)set.Items["jitter.up"]).Quantile(0.95) < 0.5);
Assert.InRange(((HistogramAcc)set.Items["jitter.down"]).Quantile(0.95)!.Value, 15, 25);
@@ -95,7 +95,7 @@ public sealed class OpTests
public void LossRuns_ReportsConsecutiveLosses()
{
bool[] lost = [false, true, true, true, false, true, false, false, true, true];
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, lost.Length, 100, i => new EchoPacket(false, lost[i], 20_000, 20_000));
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, lost.Length, 100, i => new EchoPacket(false, lost[i], 20_000, 20_000));
var runs = (RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"];
Assert.Equal(3UL, runs.Runs);
Assert.Equal(3, runs.Max);
@@ -105,7 +105,7 @@ public sealed class OpTests
[Fact]
public void LossRuns_TrailingRunIsReported()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 4, 100, i => new EchoPacket(false, i >= 2, 20_000, 20_000));
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, 4, 100, i => new EchoPacket(false, i >= 2, 20_000, 20_000));
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
}
@@ -114,7 +114,7 @@ public sealed class OpTests
{
bool[] upLost = [false, true, true, false, false, false, true, true];
bool[] downLost = [false, false, true, true, false, true, false, true];
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, upLost.Length, 100,
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, upLost.Length, 100,
i => new EchoPacket(upLost[i], downLost[i], 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
@@ -135,7 +135,7 @@ public sealed class OpTests
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
{
// 包 3 的回复比包 4 晚到
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 6, 100, i => new EchoPacket(false, false, 20_000, i == 3 ? 150_000 : 20_000));
RawChunk chunk = ChunkFactory.Echo(s_agent, "quic_ping", T0, 6, 100, i => new EchoPacket(false, false, 20_000, i == 3 ? 150_000 : 20_000));
MetricSet set = EvaluateOneBucket(chunk, "reorder", "dup");
Assert.Equal(1UL, ((CounterAcc)set.Items["reorder"]).Value);
Assert.False(set.Items.ContainsKey("dup"));
@@ -146,7 +146,7 @@ public sealed class OpTests
{
// 第 40 个包之后的 10 个包被队头阻塞,全部在 2540 ms 之后几乎同时到达
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 100, 50,
s_agent, "tcp_ping", T0, 100, 50,
i => new EchoPacket(false, false, 20_000, i is >= 40 and < 50 ? ((2540 + (i - 40) - (50 * i) - 20) * 1000) - 50 : 20_000));
MetricSet set = EvaluateOneBucket(chunk, "tcp.stall", "tcp.stall_max");
Assert.Equal(1UL, ((CounterAcc)set.Items["tcp.stall"]).Value);
@@ -156,7 +156,7 @@ public sealed class OpTests
[Fact]
public void Stall_SteadyArrivals_AreNotStalls()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 20_000, 20_000));
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 100, 50, _ => new EchoPacket(false, false, 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "tcp.stall");
Assert.False(set.Items.ContainsKey("tcp.stall"));
}
@@ -166,7 +166,7 @@ public sealed class OpTests
{
// 基线 50 ms(单程 25 ms),阈值 = max(90, 50 + max(15, 17.5)) = 90 ms;第 10、20、30 个回显的 RTT 是 200 ms
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 100, 50,
s_agent, "tcp_ping", T0, 100, 50,
i => new EchoPacket(false, false, 25_000, i % 10 == 0 && i > 0 && i < 40 ? 175_000 : 25_000, ProcessUs: 0));
var slow = (RatioAcc)EvaluateOneBucket(chunk, "tcp.slow").Items["tcp.slow"];
Assert.Equal(100UL, slow.Denominator);
@@ -176,10 +176,10 @@ public sealed class OpTests
[Fact]
public void Slow_ASteadyConnection_HasNoLongTail_AndTooFewSamplesGiveNoObservation()
{
RawChunk steady = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 25_000, 25_000));
RawChunk steady = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 100, 50, _ => new EchoPacket(false, false, 25_000, 25_000));
Assert.Equal(0UL, ((RatioAcc)EvaluateOneBucket(steady, "tcp.slow").Items["tcp.slow"]).Numerator);
RawChunk few = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 50, i => new EchoPacket(false, false, 25_000, i == 5 ? 500_000 : 25_000));
RawChunk few = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 50, i => new EchoPacket(false, false, 25_000, i == 5 ? 500_000 : 25_000));
Assert.False(EvaluateOneBucket(few, "tcp.slow").Items.ContainsKey("tcp.slow"));
}
@@ -188,7 +188,7 @@ public sealed class OpTests
{
// 1000 个回显,其中 5 个 1 s 的长尾:p50 约 50 ms,p99 仍在 50 ms 附近,p99.9 落在长尾上
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 1000, 10,
s_agent, "tcp_ping", T0, 1000, 10,
i => new EchoPacket(false, false, 25_000, i % 200 == 0 && i > 0 ? 975_000 : 25_000, ProcessUs: 0), durationMs: 20_000);
var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "tcp.rtt").Items["tcp.rtt"];
MetricDef def = TcpMetrics.Rtt;
@@ -213,7 +213,7 @@ public sealed class OpTests
(baseUs + 1_000_000, 0, 40_000u, 2u, 300u),
(baseUs + 2_000_000, 0, 40_000u, 2u, 500u),
]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(2UL, retrans.Numerator);
Assert.Equal(400UL, retrans.Denominator);
@@ -224,7 +224,7 @@ public sealed class OpTests
{
long baseUs = ChunkFactory.DialerUs(0);
RawStream info = ChunkFactory.TcpInfo([(baseUs, 0, 1u, 10u, 100u), (baseUs + 1_000_000, 0, 1u, 0u, 50u), (baseUs + 2_000_000, 0, 1u, 1u, 150u)]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(1UL, retrans.Numerator);
Assert.Equal(100UL, retrans.Denominator);
@@ -234,12 +234,12 @@ public sealed class OpTests
public void Retrans_InterleavedConnections_DoNotCreateFalseRetransmissions()
{
long ts = ChunkFactory.DialerUs(0);
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
var builder = new StreamBuilder(Kinds.TcpPing.FindStream("tcp_info")!);
builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1);
builder.Add(ts + 1_000_000, 0, 40_000, 0, 10, 20, 1100, 0, 2);
builder.Add(ts + 2_000_000, 0, 40_000, 0, 10, 11, 1100, 0, 1);
builder.Add(ts + 3_000_000, 0, 40_000, 0, 10, 21, 1200, 0, 2);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100,
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100,
_ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [builder.Build()]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(2UL, retrans.Numerator);
@@ -256,7 +256,7 @@ public sealed class OpTests
(ts + 1_000_000, 0, 40_000u, 1u, 200u),
(ts + 3_000_000, 0, 40_000u, 3u, 400u),
]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100,
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100,
_ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(3UL, retrans.Numerator);
@@ -267,12 +267,12 @@ public sealed class OpTests
public void Retrans_EqualConnectionIdsOnDifferentSides_AreIndependent()
{
long ts = ChunkFactory.DialerUs(0);
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
var builder = new StreamBuilder(Kinds.TcpPing.FindStream("tcp_info")!);
builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1);
builder.Add(ts, 1, 40_000, 0, 10, 100, 2000, 0, 1);
builder.Add(ts + 1_000_000, 0, 40_000, 0, 10, 11, 1100, 0, 1);
builder.Add(ts + 1_000_000, 1, 40_000, 0, 10, 102, 2200, 0, 1);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100,
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100,
_ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [builder.Build()]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(3UL, retrans.Numerator);
@@ -283,12 +283,12 @@ public sealed class OpTests
public void Retrans_ForwardAndReturnAreSplitBySide()
{
long ts = ChunkFactory.DialerUs(0);
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
var builder = new StreamBuilder(Kinds.TcpPing.FindStream("tcp_info")!);
builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1);
builder.Add(ts, 1, 40_000, 0, 10, 100, 2000, 0, 1);
builder.Add(ts + 1_000_000, 0, 40_000, 0, 10, 11, 1100, 0, 1);
builder.Add(ts + 1_000_000, 1, 40_000, 0, 10, 102, 2200, 0, 1);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100,
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_ping", T0, 10, 100,
_ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [builder.Build()]);
var forward = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans.up").Items["tcp.retrans.up"];
var back = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans.down").Items["tcp.retrans.down"];
@@ -322,9 +322,9 @@ public sealed class OpTests
// 10 秒的块,按 1 秒分桶:每个桶 10 个包
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0);
var buckets = new SortedDictionary<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, 1000, buckets, new HashSet<string> { "n.udp_echo" });
WindowEvaluator.Default.Evaluate(chunk, 1000, buckets, new HashSet<string> { "n.quic_ping" });
Assert.Equal(10, buckets.Count);
Assert.All(buckets.Values, set => Assert.Equal(10UL, ((CounterAcc)set.Items["n.udp_echo"]).Value));
Assert.All(buckets.Values, set => Assert.Equal(10UL, ((CounterAcc)set.Items["n.quic_ping"]).Value));
Assert.Equal(T0, buckets.Keys.First());
}
@@ -22,8 +22,8 @@ public sealed class AgentDataPlaneTests : IClassFixture<BackendFixture>
AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
DialerSimulator dialer = await agent.ConnectDialerAsync();
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo"));
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping" && t.Port == agent.App.QuicPorts[0]));
return (agent, dialer, set.Tasks.Single(t => t.Kind == "quic_ping"));
}
[Fact]
@@ -23,8 +23,8 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
DialerSimulator dialer = await agent.ConnectDialerAsync();
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo"));
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping" && t.Port == agent.App.QuicPorts[0]));
return (agent, dialer, set.Tasks.Single(t => t.Kind == "quic_ping"));
}
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
@@ -61,7 +61,7 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
}, because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} outbox={agent.App.Link.Outbox.PendingCount}");
JsonNode chunk = stored["items"]![0]!;
Assert.True(chunk["kind"] is not null, stored.ToJsonString());
Assert.Equal("udp_echo", chunk["kind"]!.GetValue<string>());
Assert.Equal("quic_ping", chunk["kind"]!.GetValue<string>());
Assert.True(agent.App.Counters.Fragments >= 1);
Assert.True(agent.App.Counters.PacketsIn >= 40);
}
@@ -76,11 +76,11 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
await using (agent)
await using (dialer)
{
LineTask tcp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo");
LineTask tcp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_ping" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_ping");
(int sent, int received) = await dialer.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls);
Assert.Equal(15, sent);
Assert.Equal(15, received);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "tcp_echo"));
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "tcp_ping"));
}
}
@@ -98,8 +98,8 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
// The dialer process restarts: a new session with its own streams; sequence numbers start from 0 again
await using DialerSimulator second = first.Restarted();
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId);
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping" && t.Port == agent.App.QuicPorts[0]));
LineTask udp2 = set.Tasks.Single(t => t.Kind == "quic_ping");
Assert.NotEqual(udp.StreamId, udp2.StreamId);
// The previous session's streams are released with it ...
@@ -135,9 +135,9 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
Assert.Equal(agent.Id, dialerView["outgoing"]![0]!["listener_id"]!.GetValue<string>());
// Both get their own task set (own streams)
LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo"));
LineTaskSet setB = await b.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo"));
Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "udp_echo").StreamId, setB.Tasks.Single(t => t.Kind == "udp_echo").StreamId);
LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping"));
LineTaskSet setB = await b.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping"));
Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "quic_ping").StreamId, setB.Tasks.Single(t => t.Kind == "quic_ping").StreamId);
// Drop one: the other stays
using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/dialers/{a.DialerId}/disconnect", null);
@@ -172,7 +172,7 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
await agent.OverrideLineTasksAsync();
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
var phase = new TestPhase { Kind = "quic_stream", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
var run = new TestRunParams { TestId = new TestId("tst_x"), DialerId = new NodeId("agt_nobody"), Plan = "x", Phases = [phase] };
Assert.Equal("dialer_offline", agent.App.Tests.Start(run).Error.Code.Value);
@@ -105,19 +105,19 @@ public sealed class AgentHarness : IAsyncDisposable
public async Task OverrideLineTasksAsync(int? rateHz = null)
{
JsonNode tasks = await Backend.GetJsonAsync(Backend.Admin, "/api/admin/tasks");
// 先处理 connect(关掉),再改 udp_echo / tcp_echo 的速率:否则中途的带宽预算会多出 connect 那一份
foreach (JsonNode? task in tasks["items"]!.AsArray().OrderBy(t => t!["kind"]!.GetValue<string>() == "connect" ? 0 : 1))
// 先处理 connect(关掉),再改 quic_ping / tcp_ping 的速率:否则中途的带宽预算会多出 connect 那一份
foreach (JsonNode? task in tasks["items"]!.AsArray().OrderBy(t => t!["kind"]!.GetValue<string>() == "tcp_connect" ? 0 : 1))
{
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo" or "connect"))
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("quic_ping" or "tcp_ping" or "tcp_connect"))
{
continue;
}
string kind = task["kind"]!.GetValue<string>();
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.QuicPorts[0] : App.TcpPorts[0] };
var parameters = new JsonObject { ["port"] = kind == "quic_ping" ? App.QuicPorts[0] : App.TcpPorts[0] };
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
bool enabled = kind != "connect";
if (rateHz is { } hz && kind != "connect")
bool enabled = kind != "tcp_connect";
if (rateHz is { } hz && kind != "tcp_connect")
{
parameters["adaptive"] = false; // 固定频率:用例按这个速率发包,限速也按它算
parameters["rate_hz"] = hz;
@@ -41,7 +41,7 @@ public sealed class AgentMeshTests : IClassFixture<BackendFixture>
await BackendFixture.EventuallyAsync(async () =>
{
JsonNode chunks = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={listener.Id}");
return chunks["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "udp_echo" && c["series"]!.GetValue<string>() == "line:" + dialer.Id);
return chunks["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "quic_ping" && c["series"]!.GetValue<string>() == "line:" + dialer.Id);
}, TimeSpan.FromSeconds(40), "the line should persist measured packet data");
}
}
@@ -22,15 +22,15 @@ public sealed class AgentTaskSetEndToEndTests : IClassFixture<BackendFixture>
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
await using DialerSimulator dialer = await agent.ConnectDialerAsync();
LineTask udp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])))
.Tasks.Single(t => t.Kind == "udp_echo");
LineTask udp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "quic_ping" && t.Port == agent.App.QuicPorts[0])))
.Tasks.Single(t => t.Kind == "quic_ping");
JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks");
JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue<string>() == agent.Id && t["kind"]!.GetValue<string>() == "udp_echo")!;
JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue<string>() == agent.Id && t["kind"]!.GetValue<string>() == "quic_ping")!;
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue<string>(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
disabled.EnsureSuccessStatusCode();
LineTaskSet next = await dialer.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0);
LineTaskSet next = await dialer.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "quic_ping") && s.Version > 0);
Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id);
}
}
@@ -58,28 +58,28 @@ public sealed class AotSmokeTests
string lineId = $"{agent.NodeId.Value}~{dialerId}";
await agent.ConnectAsync(baseAddress);
TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2);
Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo");
Assert.Contains(tasks.Tasks, t => t.Kind == "quic_ping");
// 原始块:二进制通道 → 段文件与索引 → ACK
long minute = 60_000;
long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute;
for (int k = 0; k < 6; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000))));
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "quic_ping", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000))));
}
// 查询:热层的精确汇总,随后是持久层的分钟窗口
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + minute)}&metrics=loss.total,rtt.p95";
JsonNode hot = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1s");
Assert.Equal("hot", hot["source"]!.GetValue<string>());
Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(600, hot["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
long deadline = TimeProvider.System.GetTimestamp();
JsonNode persisted;
do
{
persisted = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1m");
if (persisted["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600)
if (persisted["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 600)
{
break;
}
@@ -88,7 +88,7 @@ public sealed class AotSmokeTests
}
while (TimeProvider.System.GetElapsedTime(deadline) < TimeSpan.FromSeconds(15));
Assert.Equal("1m", persisted["source"]!.GetValue<string>());
Assert.Equal(600, persisted["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(600, persisted["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
// 原始块可以按 ID 下载并解码
JsonNode chunks = await GetAsync(http, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=1");
@@ -194,7 +194,7 @@ public sealed class ApiBasicsTests : IClassFixture<BackendFixture>
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
JsonNode kinds = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/catalog/kinds");
JsonNode udp = kinds["items"]!.AsArray().Single(k => k!["id"]!.GetValue<string>() == "udp_echo")!;
JsonNode udp = kinds["items"]!.AsArray().Single(k => k!["id"]!.GetValue<string>() == "quic_ping")!;
JsonNode rate = udp["params"]!.AsArray().Single(p => p!["name"]!.GetValue<string>() == "rate_hz")!;
Assert.Equal(1, rate["min"]!.GetValue<int>());
Assert.Equal(200, rate["max"]!.GetValue<int>());
@@ -11,7 +11,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。</summary>
/// <summary>首次启动种入的默认线路任务模板数:quic_ping、tcp_ping、connect。</summary>
internal static class Defaults
{
public const int TaskCount = 3;
@@ -59,7 +59,7 @@ public sealed class DialerEndToEndTests : IClassFixture<BackendFixture>
TimeSpan.FromSeconds(90));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "quic_stream") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
}
@@ -84,7 +84,7 @@ public sealed class DialerEndToEndTests : IClassFixture<BackendFixture>
},
TimeSpan.FromSeconds(150));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments");
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_transfer") >= 1, because: "the bulk phase stores its fragments");
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
}
}
@@ -24,15 +24,15 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
Assert.Equal("active", agent.EnrollmentStatus);
Assert.Equal("active", agent.AuthStatus);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
Assert.Equal(["connect", "tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
Assert.Equal(["quic_ping", "tcp_connect", "tcp_ping"], set.Tasks.Select(t => t.Kind).Order().ToArray());
Assert.All(set.Tasks, t =>
{
Assert.Equal("line", t.Scope);
Assert.Equal(TaskSchedule.Continuous, t.Schedule);
Assert.NotEmpty(t.Params); // 参数已经补全了默认值
});
Assert.Equal(10, set.Tasks.Single(t => t.Kind == "udp_echo").Params["rate_hz"].GetInt64());
Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_echo").Params["port"].GetInt64());
Assert.Equal(10, set.Tasks.Single(t => t.Kind == "quic_ping").Params["rate_hz"].GetInt64());
Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_ping").Params["port"].GetInt64());
Assert.True(set.Version >= 1);
JsonNode view = await AgentAsync(agent.NodeId.Value);
@@ -46,9 +46,9 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
[Fact]
public async Task TaskSet_OnlyContainsKindsTheAgentSupports()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["udp_echo"]);
await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["quic_ping"]);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 1);
Assert.Equal(["udp_echo"], set.Tasks.Select(t => t.Kind).ToArray());
Assert.Equal(["quic_ping"], set.Tasks.Select(t => t.Kind).ToArray());
}
[Fact]
@@ -265,12 +265,12 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo");
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "quic_ping");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line",
["kind"] = "udp_echo",
["kind"] = "quic_ping",
["agent_id"] = agent.NodeId.Value,
["base_task_id"] = udp.Id.Value,
["params"] = new JsonObject { ["rate_hz"] = 20 },
@@ -278,15 +278,15 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString());
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20));
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "quic_ping" && t.Params["rate_hz"].GetInt64() == 20));
Assert.Equal(Defaults.TaskCount, updated.Tasks.Count);
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64());
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "quic_ping").Params["port"].GetInt64());
// 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响)
string overrideId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo"));
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "quic_ping"));
Assert.Equal(Defaults.TaskCount - 1, disabled.Tasks.Count);
}
@@ -295,7 +295,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
{
using HttpResponseMessage badParams = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 },
["scope"] = "line", ["kind"] = "quic_ping", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 },
}));
Assert.Equal(HttpStatusCode.BadRequest, badParams.StatusCode);
JsonNode problem = JsonNode.Parse(await badParams.Content.ReadAsStringAsync())!;
@@ -308,33 +308,107 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
using HttpResponseMessage overBudget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 },
["scope"] = "line", ["kind"] = "quic_ping", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 },
}));
Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode);
Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_echo" }));
using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_ping" }));
Assert.Equal(HttpStatusCode.BadRequest, wrongScope.StatusCode);
// 目标任务:只接受 IP(不做 DNS 解析),需要指定 Agent
// 单个 Agent 的任务:目标的格式按 Kind 检查(Backend 不解析域名),需要指定 Agent
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
using HttpResponseMessage dns = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
foreach ((string kind, string badTarget) in new[] { ("icmp_ping", "https://example.com/"), ("icmp_ping", "not a host"), ("http_request", "example.com") })
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "example.com",
using HttpResponseMessage malformed = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = kind, ["agent_id"] = agent.NodeId.Value, ["target"] = badTarget,
}));
Assert.Equal(HttpStatusCode.BadRequest, malformed.StatusCode);
}
using HttpResponseMessage page = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "http_request", ["agent_id"] = agent.NodeId.Value, ["target"] = "https://Example.com/health", ["params"] = new JsonObject { ["version"] = "h3" },
}));
Assert.Equal(HttpStatusCode.BadRequest, dns.StatusCode);
Assert.Equal(HttpStatusCode.Created, page.StatusCode);
Assert.Equal("https://example.com/health", JsonNode.Parse(await page.Content.ReadAsStringAsync())!["target"]!.GetValue<string>());
using HttpResponseMessage target = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1",
["scope"] = "target", ["kind"] = "icmp_ping", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1",
}));
Assert.Equal(HttpStatusCode.Created, target.StatusCode);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target"));
Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target").Target);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target" && t.Kind == "icmp_ping"));
Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target" && t.Kind == "icmp_ping").Target);
using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["schedule"] = "every:30" }));
using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "quic_ping", ["schedule"] = "every:30" }));
Assert.Equal(HttpStatusCode.BadRequest, onceSchedule.StatusCode);
}
[Fact]
public async Task TargetTemplate_AppliesToEveryAgent_AndAnOverrideCanDisableOrRetargetItForOne()
{
await using AgentSimulator first = await _backend.ConnectAgentAsync();
await using AgentSimulator second = await _backend.ConnectAgentAsync();
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["target"] = "Example.com:443",
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
JsonNode template = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string templateId = template["id"]!.GetValue<string>();
Assert.Null(template["agent_id"]);
try
{
foreach (AgentSimulator agent in new[] { first, second })
{
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Kind == "tcp_connect" && t.Scope == "target"));
Assert.Equal("example.com:443", set.Tasks.Single(t => t.Kind == "tcp_connect" && t.Scope == "target").Target);
}
// 展示接口列出每个 Agent 实际执行的目标;模拟的 Agent 没有上传数据,所以是「等待数据」
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/public/targets");
string[] both = [first.NodeId.Value, second.NodeId.Value];
JsonNode[] mine = [.. listed["items"]!.AsArray().Select(i => i!).Where(i => i["task_id"]!.GetValue<string>() == templateId && both.Contains(i["agent_id"]!.GetValue<string>()))];
Assert.Equal(both.Order(), mine.Select(i => i["agent_id"]!.GetValue<string>()).Order());
Assert.All(mine, i =>
{
Assert.Equal("example.com:443", i["target"]!.GetValue<string>());
Assert.Equal("waiting", i["status"]!.GetValue<string>());
Assert.Null(i["last_sample"]);
});
JsonNode one = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/targets?agent_id={first.NodeId.Value}&task_id={templateId}&full=true");
Assert.Equal(first.NodeId.Value, Assert.Single(one["items"]!.AsArray())!["agent_id"]!.GetValue<string>());
// 覆盖:第一个 Agent 换一个目标,第二个 Agent 停用
using HttpResponseMessage retarget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = first.NodeId.Value, ["base_task_id"] = templateId, ["target"] = "192.0.2.9:8443",
}));
Assert.Equal(HttpStatusCode.Created, retarget.StatusCode);
using HttpResponseMessage off = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = second.NodeId.Value, ["base_task_id"] = templateId, ["enabled"] = false,
}));
Assert.Equal(HttpStatusCode.Created, off.StatusCode);
TaskSet firstSet = await first.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Target == "192.0.2.9:8443"));
Assert.Single(firstSet.Tasks, t => t.Kind == "tcp_connect" && t.Scope == "target");
await second.WaitForTaskSetAsync(s => s.Tasks.All(t => t.Scope != "target"));
// 覆盖只能指向同类的单个 Agent 模板
using HttpResponseMessage wrongKind = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_ping", ["agent_id"] = first.NodeId.Value, ["base_task_id"] = templateId,
}));
Assert.Equal(HttpStatusCode.BadRequest, wrongKind.StatusCode);
}
finally
{
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/tasks/" + templateId);
}
}
[Fact]
public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin()
{
@@ -344,7 +418,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443",
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443",
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["rejected"]!.AsArray().Count == 1);
@@ -29,7 +29,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
{
long start = baseMs + (k * 10_000L);
RawChunk udp = ChunkFactory.Echo(
agent.NodeId, "udp_echo", start, 100, 100,
agent.NodeId, "quic_ping", start, 100, 100,
i => new EchoPacket(false, lossEvery > 0 && i % lossEvery == 0, (long)(owdMs * 1000), (long)(owdMs * 1000)));
Assert.True(await agent.SendChunkAsync(udp));
if (tcp)
@@ -37,7 +37,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
long dialerBase = ChunkFactory.DialerUs(0);
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (dialerBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
RawChunk tcpChunk = ChunkFactory.Echo(
agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]);
agent.NodeId, "tcp_ping", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]);
Assert.True(await agent.SendChunkAsync(tcpChunk));
}
}
@@ -87,13 +87,13 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
// Anchored at now: the last 5 minutes are empty
JsonNode atNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total");
Assert.Null(atNow["summary"]!["samples"]!["udp_echo"]);
Assert.Null(atNow["summary"]!["samples"]!["quic_ping"]);
// Anchored at the latest sample: the window ends where the data ends and carries all of it
JsonNode anchored = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=latest");
long to = DateTimeOffset.Parse(anchored["to"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture).ToUnixTimeMilliseconds();
Assert.InRange(to, baseMs + (50 * 1000), baseMs + (80 * 1000));
Assert.Equal(600, anchored["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(600, anchored["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.False(anchored["live"]!.GetValue<bool>());
// 这个窗口是以“最后一个样本”为终点的绝对区间,但线路恢复之后同一个地址的内容就变了:不能被浏览器永久缓存
@@ -102,7 +102,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
// anchor=now is the default behaviour; anything else is rejected
JsonNode explicitNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=now");
Assert.Null(explicitNow["summary"]!["samples"]!["udp_echo"]);
Assert.Null(explicitNow["summary"]!["samples"]!["quic_ping"]);
using HttpResponseMessage bad = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&anchor=yesterday");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, bad.StatusCode);
}
@@ -162,8 +162,8 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
{
minute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
// 窗口会随着后续的块到达而重算:等到 18 个块的样本都计入之后再比较
return minute["source"]!.GetValue<string>() == "1m" && minute["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1800
&& minute["summary"]!["samples"]!["tcp_echo"]?.GetValue<long>() == 3600;
return minute["source"]!.GetValue<string>() == "1m" && minute["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1800
&& minute["summary"]!["samples"]!["tcp_ping"]?.GetValue<long>() == 3600;
});
JsonNode hotSummary = hot["summary"]!["metrics"]!;
@@ -176,9 +176,9 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
$"{id}: hot={hotSummary[id]} persisted={minuteSummary[id]}");
}
Assert.Equal(1800, hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>(), minute["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_echo"]!.GetValue<long>());
Assert.Equal(1800, hot["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.Equal(hot["summary"]!["samples"]!["quic_ping"]!.GetValue<long>(), minute["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_ping"]!.GetValue<long>());
// RTT = 2 × 20 ms,直方图的相对误差 ≤ 4.5%
Assert.InRange(minuteSummary["rtt.p95"]!.GetValue<double>(), 40 * 0.955, 40 * 1.045);
@@ -234,7 +234,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
Assert.True(goodScore > 90, $"clean line scored {goodScore}");
Assert.True(badScore < goodScore - 30, $"lossy line scored {badScore}");
Assert.Equal(20.0, lines[badIndex]["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
// 按指标排序:loss.total 升序时好线路在前
JsonNode byLoss = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range + "&sort=loss.total&order=asc");
@@ -372,12 +372,12 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
await using (agent)
{
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=1m&preset=udp";
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 600);
// Agent 重连后补传一个迟到的块(落在同一个分钟窗口里)
RawChunk late = ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + 5_000, 100, 10, _ => new EchoPacket(false, false, 20_000, 20_000));
RawChunk late = ChunkFactory.Echo(agent.NodeId, "quic_ping", baseMs + 5_000, 100, 10, _ => new EchoPacket(false, false, 20_000, 20_000));
Assert.True(await agent.SendChunkAsync(late));
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 700, because: "the late chunk must re-enter the persistent path");
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 700, because: "the late chunk must re-enter the persistent path");
}
}
@@ -394,7 +394,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
await BackendFixture.EventuallyAsync(async () =>
{
oneMinute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
return oneMinute["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 3000;
return oneMinute["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 3000;
});
// 5 分钟层由降采样把 5 个 1 分钟窗口精确合并而来
@@ -402,7 +402,7 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
await BackendFixture.EventuallyAsync(async () =>
{
fiveMinutes = await SeriesAsync(agent.NodeId.Value, range + "&step=5m");
return fiveMinutes["source"]!.GetValue<string>() == "5m" && fiveMinutes["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 3000;
return fiveMinutes["source"]!.GetValue<string>() == "5m" && fiveMinutes["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 3000;
});
Assert.Equal(oneMinute["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 9);
Assert.Equal(oneMinute["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), 9);
@@ -30,7 +30,7 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
{
foreach (string version in new[] { IpVersions.V4, IpVersions.V6 })
{
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", start + k * 10_000, 100, 100,
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "quic_ping", start + k * 10_000, 100, 100,
i => new EchoPacket(false, version == IpVersions.V6 && i % 2 == 0, 20_000, 20_000));
chunk.Header.DialerId = new NodeId(_backend.DefaultDialerId);
chunk.Header.IpVersion = version;
@@ -50,7 +50,7 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
await BackendFixture.EventuallyAsync(async () =>
{
series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?{range}&step={step}&preset=udp");
return series["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600;
return series["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 600;
});
Assert.Equal(loss, series["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
}
@@ -99,7 +99,7 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
{
JsonNode raw = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.Id);
return new[] { "line:" + dialer.DialerId, "line:" + dialer.DialerId + ":ipv6" }.All(series =>
new[] { "udp_echo", "tcp_echo" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue<string>() == series && c["kind"]!.GetValue<string>() == kind)));
new[] { "quic_ping", "tcp_ping" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue<string>() == series && c["kind"]!.GetValue<string>() == kind)));
}, TimeSpan.FromSeconds(45));
string v4 = agent.Id + "~" + dialer.DialerId;
@@ -110,8 +110,8 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
{
JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?range=1m&step={step}&preset=udp,tcp");
Assert.Equal(family, series["ip_version"]!.GetValue<string>());
Assert.True(series["summary"]!["samples"]!["udp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
Assert.True(series["summary"]!["samples"]!["tcp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
Assert.True(series["summary"]!["samples"]!["quic_ping"]!.GetValue<long>() > 0, series.ToJsonString());
Assert.True(series["summary"]!["samples"]!["tcp_ping"]!.GetValue<long>() > 0, series.ToJsonString());
}
}
@@ -34,9 +34,9 @@ public sealed class LiveTests : IClassFixture<BackendFixture>
await sse.WaitForAsync(e => e.Name == "series" && e.Data!["agent_id"]!.GetValue<string>() == id);
// 新块到达之后:推送新的 1s 桶(列式)和最新的汇总
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", Now() - 10_000, 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000));
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "quic_ping", Now() - 10_000, 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000));
Assert.True(await agent.SendChunkAsync(chunk));
SseEvent series = await sse.WaitForAsync(e => e.Name == "series" && e.Data!["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() >= 100);
SseEvent series = await sse.WaitForAsync(e => e.Name == "series" && e.Data!["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() >= 100);
Assert.True(series.Data!["live"]!.GetValue<bool>());
Assert.Equal("1s", series.Data["step"]!.GetValue<string>());
Assert.Equal(series.Data["t"]!.AsArray().Count, series.Data["values"]!["loss.total"]!.AsArray().Count);
@@ -82,7 +82,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
TestRunParams run = agent.TestRuns.Single();
Assert.Equal(testId, run.TestId.Value);
Assert.Equal([10d, 25d, 50d, 100d], run.Phases.Select(p => p.Params["mbps"].GetDouble()).ToArray());
Assert.All(run.Phases, p => Assert.Equal("udp_train", p.Kind));
Assert.All(run.Phases, p => Assert.Equal("quic_stream", p.Kind));
Assert.All(run.Phases, p => Assert.Equal(1250, p.Params["size"].GetInt64()));
// 同一条线路同时只能跑一个测试
@@ -227,7 +227,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, now - 120_000);
Assert.True(await agent.SendChunkAsync(chunk));
JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo");
JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=quic_ping");
JsonNode item = list["items"]!.AsArray().Single()!;
Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue<string>());
Assert.Equal("line:" + _backend.DefaultDialerId, item["series"]!.GetValue<string>());
@@ -42,7 +42,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture<BackendFixture>
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000))));
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "quic_ping", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000))));
}
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&step=1m&metrics=rtt.p95,loss.total";
@@ -50,7 +50,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture<BackendFixture>
await BackendFixture.EventuallyAsync(async () =>
{
before = await _backend.GetJsonAsync(_backend.Admin, url);
return before["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200 && before["source"]!.GetValue<string>() == "1m";
return before["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1200 && before["source"]!.GetValue<string>() == "1m";
});
double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>();
Assert.InRange(rtt, 60 * 0.955, 60 * 1.045);
@@ -21,14 +21,14 @@ public sealed class PersistenceTests : IAsyncLifetime
string id = agent.NodeId.Value;
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000))));
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "quic_ping", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000))));
}
string from = DateTimeOffset.FromUnixTimeMilliseconds(baseMs).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string to = DateTimeOffset.FromUnixTimeMilliseconds(baseMs + (2 * minute)).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string url = $"/api/public/lines/{_backend.Line(id)}/series?from={from}&to={to}&metrics=loss.total,rtt.p95";
JsonNode before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1200);
before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await agent.DisposeAsync();
@@ -51,7 +51,7 @@ public sealed class PersistenceTests : IAsyncLifetime
await BackendFixture.EventuallyAsync(async () =>
{
hot = await _backend.GetJsonAsync(admin, url + "&step=1s");
return hot["source"]!.GetValue<string>() == "hot" && hot["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200;
return hot["source"]!.GetValue<string>() == "hot" && hot["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1200;
});
Assert.Equal(25.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
@@ -72,8 +72,8 @@ public sealed class ScheduleTests : IClassFixture<BackendFixture>
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonArray items = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["items"]!.AsArray();
string[] kinds = [.. items.Select(i => i!["kind"]!.GetValue<string>())];
Assert.Contains("connect", kinds);
Assert.Contains("udp_echo", kinds);
Assert.Contains("tcp_echo", kinds);
Assert.Contains("tcp_connect", kinds);
Assert.Contains("quic_ping", kinds);
Assert.Contains("tcp_ping", kinds);
}
}
@@ -48,8 +48,8 @@ public sealed class StorageTests : IDisposable
public async Task Raw_AppendReadAndDedupe()
{
var raw = new SqliteRawStore(_db);
RawChunkAppend a = Chunk(s_agent, "udp_echo", 1_000, "2026-01-01");
RawChunkAppend b = Chunk(s_agent, "udp_echo", 11_000, "2026-01-01");
RawChunkAppend a = Chunk(s_agent, "quic_ping", 1_000, "2026-01-01");
RawChunkAppend b = Chunk(s_agent, "quic_ping", 11_000, "2026-01-01");
Assert.True(await raw.AppendAsync(a, default));
Assert.True(await raw.AppendAsync(b, default));
@@ -57,7 +57,7 @@ public sealed class StorageTests : IDisposable
IReadOnlyList<RawChunkMeta> metas = await raw.FindAsync(new RawQuery(s_agent), default);
Assert.Equal(2, metas.Count);
Assert.Equal("2026-01-01/agt_storage/udp_echo.seg", metas[0].SegmentPath);
Assert.Equal("2026-01-01/agt_storage/quic_ping.seg", metas[0].SegmentPath);
Assert.Equal(0, metas[0].Offset);
Assert.Equal(metas[0].Length, metas[1].Offset); // 同一个段文件里依次追加
Assert.Equal(a.Bytes.ToArray(), await raw.ReadAsync(metas[0], default));
@@ -72,7 +72,7 @@ public sealed class StorageTests : IDisposable
var raw = new SqliteRawStore(_db);
var evil = new NodeId("../../../outside");
Assert.True(await raw.AppendAsync(Chunk(evil, "../etc", 1, "2026-01-01"), default));
Assert.True(await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-01-01", test: new TestId("..\\..\\x"), phase: 0), default));
Assert.True(await raw.AppendAsync(Chunk(s_agent, "quic_ping", 2, "2026-01-01", test: new TestId("..\\..\\x"), phase: 0), default));
foreach (string file in Directory.EnumerateFiles(_dir, "*.seg", SearchOption.AllDirectories))
{
@@ -80,7 +80,7 @@ public sealed class StorageTests : IDisposable
}
Assert.False(File.Exists(Path.Combine(_dir, "outside")));
await Assert.ThrowsAsync<ArgumentException>(async () => await raw.AppendAsync(Chunk(s_agent, "udp_echo", 3, "../2026"), default));
await Assert.ThrowsAsync<ArgumentException>(async () => await raw.AppendAsync(Chunk(s_agent, "quic_ping", 3, "../2026"), default));
}
[Fact]
@@ -90,19 +90,19 @@ public sealed class StorageTests : IDisposable
var testId = new TestId("tst_one");
for (int i = 0; i < 6; i++)
{
await raw.AppendAsync(Chunk(s_agent, "udp_echo", i * 10_000L, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "quic_ping", i * 10_000L, "2026-01-01"), default);
}
await raw.AppendAsync(Chunk(s_agent, "tcp_echo", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(new NodeId("agt_other"), "udp_echo", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 0, "2026-01-01", test: testId, phase: 1), default);
await raw.AppendAsync(Chunk(s_agent, "tcp_ping", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(new NodeId("agt_other"), "quic_ping", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "quic_stream", 0, "2026-01-01", test: testId, phase: 1), default);
Assert.Equal(8, (await raw.FindAsync(new RawQuery(s_agent), default)).Count);
Assert.Equal(6, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo"), default)).Count);
Assert.Equal(6, (await raw.FindAsync(new RawQuery(s_agent, Kind: "quic_ping"), default)).Count);
Assert.Single(await raw.FindAsync(new RawQuery(TestId: testId), default));
Assert.Equal(3, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", FromMs: 10_000, ToMs: 40_000), default)).Count);
Assert.Equal(2, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", Limit: 2), default)).Count);
Assert.Equal(4, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", AfterStartTs: 20_000), default)).Count);
Assert.Equal(3, (await raw.FindAsync(new RawQuery(s_agent, Kind: "quic_ping", FromMs: 10_000, ToMs: 40_000), default)).Count);
Assert.Equal(2, (await raw.FindAsync(new RawQuery(s_agent, Kind: "quic_ping", Limit: 2), default)).Count);
Assert.Equal(4, (await raw.FindAsync(new RawQuery(s_agent, Kind: "quic_ping", AfterStartTs: 20_000), default)).Count);
IReadOnlyList<(NodeId AgentId, string Series)> series = await raw.ListSeriesAsync(0, 100_000, default);
Assert.Equal(2, series.Count); // 测试的块不算持续序列
@@ -112,9 +112,9 @@ public sealed class StorageTests : IDisposable
public async Task Raw_RecoverTruncatesAnUnindexedTail_AndLaterAppendsStayConsistent()
{
var raw = new SqliteRawStore(_db);
RawChunkAppend first = Chunk(s_agent, "udp_echo", 1, "2026-01-01");
RawChunkAppend first = Chunk(s_agent, "quic_ping", 1, "2026-01-01");
await raw.AppendAsync(first, default);
string segment = Directory.EnumerateFiles(_dir, "udp_echo.seg", SearchOption.AllDirectories).Single();
string segment = Directory.EnumerateFiles(_dir, "quic_ping.seg", SearchOption.AllDirectories).Single();
long indexedEnd = new FileInfo(segment).Length;
// 追加之后、写索引之前崩溃:段文件尾部多了没有索引记录的字节
@@ -124,7 +124,7 @@ public sealed class StorageTests : IDisposable
await raw.RecoverAsync(default);
Assert.Equal(indexedEnd, new FileInfo(segment).Length);
RawChunkAppend second = Chunk(s_agent, "udp_echo", 2, "2026-01-01");
RawChunkAppend second = Chunk(s_agent, "quic_ping", 2, "2026-01-01");
await raw.AppendAsync(second, default);
IReadOnlyList<RawChunkMeta> metas = await raw.FindAsync(new RawQuery(s_agent), default);
Assert.Equal(first.Bytes.ToArray(), await raw.ReadAsync(metas[0], default));
@@ -137,10 +137,10 @@ public sealed class StorageTests : IDisposable
{
var raw = new SqliteRawStore(_db);
var testId = new TestId("tst_old");
await raw.AppendAsync(Chunk(s_agent, "udp_echo", 1, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-03-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 3, "2026-01-01", test: testId, phase: 0), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 4, "2026-02-01", test: new TestId("tst_mid"), phase: 0), default);
await raw.AppendAsync(Chunk(s_agent, "quic_ping", 1, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "quic_ping", 2, "2026-03-01"), default);
await raw.AppendAsync(Chunk(s_agent, "quic_stream", 3, "2026-01-01", test: testId, phase: 0), default);
await raw.AppendAsync(Chunk(s_agent, "quic_stream", 4, "2026-02-01", test: new TestId("tst_mid"), phase: 0), default);
// 持续任务保留到 2026-02-01 之后,测试保留到 2026-01-15 之后
int removed = await raw.DeleteExpiredAsync(new DateOnly(2026, 2, 1), new DateOnly(2026, 1, 15), default);
@@ -365,6 +365,44 @@ public sealed class StorageTests : IDisposable
Assert.Equal(200, (await settings.GetAllAsync(default)).Count);
}
[Fact]
public void Schema_UpgradesVersion1_ByRenamingTheKinds()
{
string dir = Path.Combine(Path.GetTempPath(), "moshiro-schema-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "moshiro.db");
try
{
using (var db = new SqliteDatabase(dir))
{
}
SqliteConnection.ClearAllPools();
using (var connection = new SqliteConnection($"Data Source={path}"))
{
connection.Open();
connection.Exec("INSERT INTO tasks (id, scope, kind, schedule, updated_at) VALUES ('tsk_a', 'line', 'udp_echo', 'continuous', 1), ('tsk_b', 'line', 'connect', 'continuous', 1)");
connection.Exec("PRAGMA user_version = 1");
}
SqliteConnection.ClearAllPools();
using (var db = new SqliteDatabase(dir))
{
}
SqliteConnection.ClearAllPools();
using var check = new SqliteConnection($"Data Source={path}");
check.Open();
Assert.Equal(2, check.Scalar<int>("PRAGMA user_version"));
Assert.Equal("quic_ping,tcp_connect", string.Join(',', new[] { "tsk_a", "tsk_b" }.Select(id => check.Scalar<string>($"SELECT kind FROM tasks WHERE id = '{id}'"))));
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Schema_RefusesADatabaseFromANewerBuild()
{
@@ -109,7 +109,7 @@ public sealed class AgentSimulator : IAsyncDisposable
["tcp_port"] = listens ? 7443 : 0,
["quic_ports"] = listens ? new JsonArray(7443) : new JsonArray(),
["public_host"] = listens ? publicHost : "",
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
["kinds"] = new JsonArray((kinds ?? ["quic_ping", "tcp_ping", "quic_stream", "tcp_connect", "tls_hold", "tcp_transfer", "icmp_ping"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
},
};
@@ -169,7 +169,7 @@ public sealed class AgentSimulator : IAsyncDisposable
var hello = new HelloParams
{
NodeVersion = "0.0.1-sim",
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"])],
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', kinds ?? ["quic_ping", "tcp_ping", "quic_stream", "tcp_connect", "tls_hold", "tcp_transfer", "icmp_ping"])],
NowUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(),
Meta = meta,
};
@@ -71,13 +71,13 @@ public static class ChunkFactory
return new RawChunk(Header(agent, kind, startUtcMs, durationMs, parameters), streams);
}
/// <summary>10 Hz 的 udp_echo,没有丢包,RTT = 2 × owdMs。</summary>
/// <summary>10 Hz 的 quic_ping,没有丢包,RTT = 2 × owdMs。</summary>
public static RawChunk UdpEchoClean(NodeId agent, long startUtcMs, int count = 100, double owdMs = 20) =>
Echo(agent, "udp_echo", startUtcMs, count, 100, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)));
Echo(agent, "quic_ping", startUtcMs, count, 100, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)));
public static RawStream TcpInfo(IEnumerable<(long TsUs, byte Side, uint SrttUs, uint RetransTotal, uint SegsOut)> samples)
{
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
var builder = new StreamBuilder(Kinds.TcpPing.FindStream("tcp_info")!);
foreach ((long ts, byte side, uint srtt, uint retrans, uint segs) in samples)
{
builder.Add(ts, side, srtt, srtt / 4, 10, retrans, segs, segs * 100L, null);
@@ -88,7 +88,7 @@ public static class ChunkFactory
public static RawChunk Connect(NodeId agent, long startUtcMs, IEnumerable<(long TcpUs, long? TlsUs, ConnectOutcomeCode Outcome)> attempts)
{
var builder = new StreamBuilder(Kinds.Connect.FindStream("conn")!);
var builder = new StreamBuilder(Kinds.TcpConnect.FindStream("conn")!);
int i = 0;
foreach ((long tcp, long? tls, ConnectOutcomeCode outcome) in attempts)
{
@@ -96,16 +96,16 @@ public static class ChunkFactory
i++;
}
return new RawChunk(Header(agent, "connect", startUtcMs), [builder.Build()]);
return new RawChunk(Header(agent, "tcp_connect", startUtcMs), [builder.Build()]);
}
/// <summary>udp_train:Agent 以固定间隔发包,Dialer 记录收到的时刻;<paramref name="lossEvery"/> 为 N 时每 N 个包丢一个。</summary>
/// <summary>quic_stream:Agent 以固定间隔发包,Dialer 记录收到的时刻;<paramref name="lossEvery"/> 为 N 时每 N 个包丢一个。</summary>
public static RawChunk Train(NodeId agent, long startUtcMs, double mbps, int phase, int seconds = 10, int lossEvery = 0, long owdUs = 20_000)
{
const int size = 1250;
int packets = (int)(mbps * 1_000_000 / 8 / size * seconds);
double intervalUs = seconds * 1_000_000.0 / packets;
var pkt = new StreamBuilder(Kinds.UdpTrain.FindStream("pkt")!);
var pkt = new StreamBuilder(Kinds.QuicStream.FindStream("pkt")!);
for (int i = 0; i < packets; i++)
{
long sendAgent = AgentMonoAnchorUs + (long)(i * intervalUs);
@@ -119,10 +119,10 @@ public static class ChunkFactory
["mbps"] = JsonSerializer.SerializeToElement(mbps, FactoryJson.Default.Double),
["duration_s"] = JsonSerializer.SerializeToElement((double)seconds, FactoryJson.Default.Double),
};
RawChunkHeader header = Header(agent, "udp_train", startUtcMs, seconds * 1000, parameters);
RawChunkHeader header = Header(agent, "quic_stream", startUtcMs, seconds * 1000, parameters);
header.Phase = phase;
header.TestId = new TestId("tst_test");
return new RawChunk(header, [pkt.Build(), new StreamBuilder(Kinds.UdpTrain.FindStream("step")!).Build()]);
return new RawChunk(header, [pkt.Build(), new StreamBuilder(Kinds.QuicStream.FindStream("step")!).Build()]);
}
}
@@ -138,7 +138,7 @@ public sealed class DialerSimulator : IAsyncDisposable
}
}
/// <summary>对一个 udp_echo 线路任务发 <paramref name="count"/> 个回显包,等回复,然后交出一个列块。返回交给 Agent 的列块的序号范围。</summary>
/// <summary>对一个 quic_ping 线路任务发 <paramref name="count"/> 个回显包,等回复,然后交出一个列块。返回交给 Agent 的列块的序号范围。</summary>
public async Task<(int Sent, int Received)> RunUdpEchoAsync(LineTask task, int count, TimeSpan interval, CancellationToken ct = default)
{
int size = (int)task.Params["size"].GetInt64();
@@ -166,12 +166,12 @@ public sealed class DialerSimulator : IAsyncDisposable
msg.Add(seq, size, 1, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
}
RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
RawChunkHeader header = Header("quic_ping", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
return (count, collector.Received.Count);
}
/// <summary>对一个 tcp_echo 线路任务建立一条 TCP(可选 TLS)连接,发 <paramref name="count"/> 条回显消息,然后交出一个列块。</summary>
/// <summary>对一个 tcp_ping 线路任务建立一条 TCP(可选 TLS)连接,发 <paramref name="count"/> 条回显消息,然后交出一个列块。</summary>
public async Task<(int Sent, int Received)> RunTcpEchoAsync(LineTask task, int tcpPort, int count, TimeSpan interval, bool tls, CancellationToken ct = default)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -211,7 +211,7 @@ public sealed class DialerSimulator : IAsyncDisposable
await Task.Delay(interval, ct);
}
RawChunkHeader header = Header("tcp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
RawChunkHeader header = Header("tcp_ping", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
return (count, received);
}
@@ -394,7 +394,7 @@ public sealed class DialerSimulator : IAsyncDisposable
}
_activePhases[start.StreamId] = start;
if (start.Kind == "udp_train")
if (start.Kind == "quic_stream")
{
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
_quic!.Register(start.StreamId, OnDatagram);
@@ -424,7 +424,7 @@ public sealed class DialerSimulator : IAsyncDisposable
return 0;
}
if (start.Kind == "udp_train" && _trains.TryRemove(end.StreamId, out TrainCollector? train))
if (start.Kind == "quic_stream" && _trains.TryRemove(end.StreamId, out TrainCollector? train))
{
await Task.Delay(200, ct); // 最后一批包还在路上
// 单块的记录数有上限(ProtocolLimits.MaxRecordsPerChunk):按序号范围切成多个列块,Agent 一侧按同样的范围取行
@@ -443,12 +443,12 @@ public sealed class DialerSimulator : IAsyncDisposable
var streams = new List<RawStream> { pkt.Build() };
if (fragments == 0)
{
var step = new StreamBuilder(Kinds.UdpTrain.FindStream("step")!);
var step = new StreamBuilder(Kinds.QuicStream.FindStream("step")!);
step.Add(0, (long)start.Params["mbps"].GetDouble(), NowUs() - (start.DurationSeconds * 1_000_000L), NowUs());
streams.Add(step.Build());
}
RawChunkHeader header = Header("udp_train", null, start.TestId, start.Phase, train.StartUtcMs, start.DurationSeconds * 1000, start.Params);
RawChunkHeader header = Header("quic_stream", null, start.TestId, start.Phase, train.StartUtcMs, start.DurationSeconds * 1000, start.Params);
header.Params[FragmentKeys.SeqFrom] = JsonSerializer.SerializeToElement(from);
header.Params[FragmentKeys.SeqTo] = JsonSerializer.SerializeToElement(to);
// 不能在这里等确认:确认是 Agent 的通知,由同一个入站队列顺序处理,而当前正处在 phase_end 的处理函数里
+62
View File
@@ -548,3 +548,65 @@
.agent-ipv6 > .kv {
margin-top: 8px;
}
.task-kind {
display: grid;
gap: 2px;
}
.task-params {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
max-width: 360px;
font-size: 12px;
color: var(--app-ink-muted);
}
.task-params b {
color: var(--app-ink);
font-weight: 500;
}
.param-more {
margin-top: 4px;
}
.param-more > summary {
cursor: pointer;
margin-bottom: 8px;
color: var(--app-ink-muted);
font-size: 13px;
}
.task-agents {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
max-height: 160px;
overflow: auto;
padding: 8px 12px;
border: 1px solid var(--app-outline-soft);
border-radius: 12px;
}
.task-kind-id {
font-size: 12px;
}
.task-child td {
padding-top: 6px;
padding-bottom: 6px;
}
.task-child .task-branch {
display: inline-block;
padding-left: 14px;
color: var(--app-ink-faint);
}
.task-run {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
+1 -1
View File
@@ -752,7 +752,7 @@ function Raw() {
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="Agent ID" value={agent} onChange={(e) => setAgent(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 udp_echo" value={kind} onChange={(e) => setKind(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 quic_ping" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="muted">原始块按保留期限保存,可以下载后离线分析。</span>
</div>
<ErrorNotice error={chunks.error} />
+301 -161
View File
@@ -1,18 +1,61 @@
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, KindDto, Page, ParamDto, TaskDto } from '@shared/api/types'
import type { AgentDto, KindDto, Page, ParamDto, TargetDto, TaskDto } from '@shared/api/types'
import { TARGET_STATUS, rejectText } from '@shared/targets'
import { fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
type ScopeFilter = 'all' | 'template' | 'override' | 'target'
/** 任务分两类:Agent 之间(线路任务,两个 Agent 之间测)和单个 Agent(Agent 自己去测一个外部地址)。 */
type Category = 'between' | 'single'
type Mode = 'template' | 'override' | 'own'
const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template')
/** 模板(对所有 Agent)、覆盖(改某个模板)、专属(只属于一个 Agent 的单个 Agent 任务)。 */
const modeOf = (t: TaskDto): Mode => (!t.agent_id ? 'template' : t.base_task_id ? 'override' : 'own')
const categoryOf = (t: TaskDto): Category => (t.scope === 'target' ? 'single' : 'between')
function summary(params: Record<string, unknown>): string {
/** 采集方法的中文名、协议和一句话说明;Backend 目录里是英文,这里按 id 补充,没有登记的回退到目录里的标题。 */
const KIND_INFO: Record<string, { name: string; proto: string; desc: string }> = {
quic_ping: { name: 'QUIC 往返', proto: 'QUIC', desc: '两个 Agent 之间用 QUIC Datagram 持续往返,测 RTT、丢包、乱序;频率按丢包情况自适应。' },
tcp_ping: { name: 'TCP 往返', proto: 'TCP', desc: '两个 Agent 之间用 TCP 小消息持续往返,测 RTT、重传,并采集内核 tcp_info。' },
quic_stream: { name: 'QUIC 持续流量', proto: 'QUIC', desc: '按固定码率或阶梯发 QUIC Datagram,测高负载下的丢包和抖动。只用于按需测试。' },
tcp_transfer: { name: 'TCP 多流传输', proto: 'TCP', desc: '多条 TCP 连接并行灌包,测吞吐和重传。只用于按需测试。' },
tcp_connect: { name: 'TCP 建连', proto: 'TCP', desc: '只建立连接,不发 HTTP 请求:反复完成 TCP 三次握手(可选再做 TLS 握手),记录各阶段耗时和成功率。要测网页响应请用「HTTP 请求」。' },
tls_hold: { name: 'TLS 长连接保持', proto: 'TLS', desc: '保持一条 TLS 长连接并定期心跳,记录重置次数和存活时间。' },
icmp_ping: { name: 'ICMP 往返', proto: 'ICMP', desc: 'Agent 对目标地址发 ICMP echo,测 RTT 和丢包。' },
http_request: { name: 'HTTP 请求', proto: 'HTTP', desc: 'Agent 请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3),记录 DNS、建连、首字节、总耗时和状态码;频率按失败率自适应。' },
}
const kindName = (id: string, fallback?: string) => KIND_INFO[id]?.name ?? fallback ?? id
/** 采集方法:中文名、协议标签和 id;用在任务列表和选择框里。 */
function KindCell({ id }: { id: string }) {
const info = KIND_INFO[id]
return (
<div className="task-kind">
<b>{kindName(id)}</b>
<span className="faint task-kind-id">
{info ? `${info.proto} · ` : ''}
<span className="mono">{id}</span>
</span>
</div>
)
}
/** 参数摘要:每个参数一个小标签,换行显示。 */
function ParamChips({ params }: { params: Record<string, unknown> }) {
const entries = Object.entries(params)
return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
if (entries.length === 0) return <span className="faint">默认参数</span>
return (
<span className="task-params">
{entries.map(([k, v]) => (
<span key={k} className="mono">
{k}=<b>{String(v)}</b>
</span>
))}
</span>
)
}
/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */
@@ -23,9 +66,7 @@ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: R
else next[name] = value
onChange(next)
}
return (
<div className="form-grid">
{params.map((p) => {
const field = (p: ParamDto) => {
const current = values[p.name]
const hint = [p.description, p.min !== null && p.min !== undefined ? `最小 ${p.min}` : '', p.max !== null && p.max !== undefined ? `最大 ${p.max}` : '', p.unit ?? ''].filter(Boolean).join(' · ')
return (
@@ -57,41 +98,56 @@ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: R
)}
</Field>
)
})}
</div>
}
// 前几个是最常调的,其余折叠起来(自适应、超时这类参数很多时表单太长)
const main = params.slice(0, 4)
const more = params.slice(4)
return (
<>
<div className="form-grid">{main.map(field)}</div>
{more.length > 0 ? (
<details className="param-more">
<summary>更多参数({more.length})</summary>
<div className="form-grid">{more.map(field)}</div>
</details>
) : null}
</>
)
}
function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) {
function TaskDialog({ task, category, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; category: Category; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
const editing = Boolean(task)
const [mode, setMode] = useState<'template' | 'override' | 'target'>(task ? (kindOf(task) as 'template' | 'override' | 'target') : 'override')
const [kindId, setKindId] = useState(task?.kind ?? '')
const [agentId, setAgentId] = useState(task?.agent_id ?? agents[0]?.id ?? '')
const [baseId, setBaseId] = useState(task?.base_task_id ?? '')
const [everyone, setEveryone] = useState(task ? !task.agent_id : true)
const [picked, setPicked] = useState<string[]>(task?.agent_id ? [task.agent_id] : [])
const [target, setTarget] = useState(task?.target ?? '')
const [values, setValues] = useState<Record<string, unknown>>(task ? { ...(task.params as Record<string, unknown>) } : {})
const [enabled, setEnabled] = useState(task?.enabled ?? true)
const base = templates.find((t) => t.id === baseId)
const effectiveKind = mode === 'override' ? (base?.kind ?? '') : kindId
const kind = kinds.find((k) => k.id === effectiveKind)
const kindChoices = kinds.filter((k) => k.scopes.includes(mode === 'target' ? 'target' : 'line'))
const single = category === 'single'
const kind = kinds.find((k) => k.id === kindId)
// 「Agent 之间」的任务只有模板;只改某几个 Agent 时是覆盖这个方法的模板,所以要先有模板
const template = templates.find((t) => categoryOf(t) === category && t.kind === kindId)
const blocked = !editing && !everyone && !single && !template
const needsTarget = single && (everyone || !template)
const kindChoices = kinds.filter((k) => k.scopes.includes(single ? 'target' : 'line'))
const toggle = (id: string) => setPicked((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id]))
const submit = async () => {
const ok = await run(async () => {
if (task) {
await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: mode === 'target' ? target : undefined })
} else {
await api.post('/api/admin/tasks', {
scope: mode === 'target' ? 'target' : 'line',
kind: effectiveKind,
target: mode === 'target' ? target : null,
params: values,
enabled,
agent_id: mode === 'template' ? null : agentId,
base_task_id: mode === 'override' ? baseId : null,
})
await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: single && target ? target : undefined })
return
}
const base = { scope: single ? 'target' : 'line', kind: kindId, params: values, enabled }
if (everyone) {
await api.post('/api/admin/tasks', { ...base, target: single ? target : null, agent_id: null, base_task_id: null })
return
}
for (const agentId of picked) {
await api.post('/api/admin/tasks', { ...base, target: single && target ? target : null, agent_id: agentId, base_task_id: !single || template ? (template?.id ?? null) : null })
}
}, '已保存')
if (ok) {
@@ -100,82 +156,81 @@ function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task
}
}
const scopeLine = editing
? task?.agent_id
? `作用于 ${agents.find((a) => a.id === task.agent_id)?.name ?? task.agent_id}`
: '作用于所有 Agent,包括以后新加入的'
: everyone
? '保存后立即对所有 Agent 生效,以后新加入的 Agent 也会自动执行'
: single
? '只对选中的 Agent 生效'
: '只调整选中 Agent 的参数(覆盖该方法的模板),其他 Agent 不变'
return (
<Dialog
title={editing ? '修改任务' : '添加任务'}
title={editing ? '修改任务' : single ? '添加单个 Agent 任务' : '添加 Agent 之间的任务'}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button type="button" className="btn primary" disabled={busy || !effectiveKind || (mode !== 'template' && !agentId) || (mode === 'target' && !target) || (mode === 'override' && !baseId)} onClick={submit}>
<button type="button" className="btn primary" disabled={busy || !kind || blocked || (needsTarget && !target) || (!editing && !everyone && picked.length === 0)} onClick={submit}>
保存
</button>
</>
}
>
<div className="stack">
{!editing ? (
<Field label="类型" hint={mode === 'template' ? '线路模板:对所有 Agent 生效' : mode === 'override' ? '覆盖:只改某个 Agent 上某个模板的参数' : '目标任务:Agent 自己去测一个地址(ICMP、TCP、TLS 建连),需要 Agent 本地允许这个网段'}>
<Segmented
options={[
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'target' as const, label: '目标任务' },
]}
value={mode}
onChange={(v) => {
setMode(v)
setValues({})
setKindId('')
}}
/>
</Field>
) : null}
{mode !== 'template' ? (
<Field label="Agent">
<select className="select" value={agentId} disabled={editing} onChange={(e) => setAgentId(e.target.value)}>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
) : null}
{mode === 'override' && !editing ? (
<Field label="要覆盖的模板">
<select className="select" value={baseId} onChange={(e) => { setBaseId(e.target.value); setValues({}) }}>
<option value="">选择模板</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.kind}({summary(t.params as Record<string, unknown>)})
</option>
))}
</select>
</Field>
) : null}
{mode !== 'override' && !editing ? (
{editing ? (
<div className="task-kind">
<KindCell id={task!.kind} />
</div>
) : (
<Field label="采集方法">
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}) }}>
<option value="">选择</option>
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}); setTarget('') }}>
<option value="">选择要做什么测量</option>
{kindChoices.map((k) => (
<option key={k.id} value={k.id}>
{k.title}({k.id})
{kindName(k.id, k.title)}({k.id})
</option>
))}
</select>
</Field>
) : null}
{mode === 'target' ? (
<Field label="目标" hint="例如 1.1.1.1 或 example.com:443">
<input className="input" value={target} onChange={(e) => setTarget(e.target.value)} />
)}
{single ? (
<Field label="目标" hint={kindId === 'http_request' ? 'http:// 或 https:// 开头的 URL,例如 https://example.com/health' : 'IP 或域名,可带端口,例如 1.1.1.1、example.com:443'}>
<input className="input" value={target} placeholder={!everyone && template ? (template.target ?? '') : ''} onChange={(e) => setTarget(e.target.value)} />
</Field>
) : null}
{!editing && kind ? (
<Field label="应用到" hint={scopeLine}>
<Segmented
options={[
{ value: 'all' as const, label: '所有 Agent' },
{ value: 'some' as const, label: '指定 Agent' },
]}
value={everyone ? 'all' : 'some'}
onChange={(v) => setEveryone(v === 'all')}
/>
</Field>
) : editing ? (
<p className="muted">{scopeLine}</p>
) : null}
{!editing && kind && !everyone ? (
<div className="task-agents">
{agents.map((a) => (
<label key={a.id} className="switch">
<input type="checkbox" checked={picked.includes(a.id)} onChange={() => toggle(a.id)} />
{a.name}
</label>
))}
</div>
) : null}
{blocked ? <p className="warn-text">「{kindName(kindId)}」还没有对所有 Agent 的模板:先添加一个应用到「所有 Agent」的任务,再对某几个 Agent 单独调整。</p> : null}
{kind ? (
<>
{kind.description ? <p className="muted">{kind.description}</p> : null}
<p className="muted">{KIND_INFO[kind.id]?.desc ?? kind.description}</p>
<ParamForm params={kind.params} values={values} onChange={setValues} />
</>
) : null}
@@ -188,17 +243,149 @@ function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task
)
}
/** 单个 Agent 任务的运行情况:每个执行它的 Agent 一个状态标签;悬停看说明和原因。 */
function RunCell({ items }: { items: TargetDto[] }) {
if (items.length === 0) return <span className="faint">没有 Agent 在执行</span>
return (
<span className="task-run">
{items.map((t) => (
<span key={t.agent_id} title={`${TARGET_STATUS[t.status].hint}${t.status === 'rejected' ? `\n${rejectText(t.reason)}` : ''}${t.last_sample ? `\n最后一次数据 ${fmtTime(t.last_sample)}` : ''}`}>
<Chip tone={TARGET_STATUS[t.status].tone}>
{t.agent_name} · {TARGET_STATUS[t.status].label}
</Chip>
</span>
))}
</span>
)
}
/** 一个模板带着它的覆盖排在一起(覆盖缩进),其余的专属任务跟在后面;这样同一个方法不会在列表里到处出现。 */
function groupRows(tasks: TaskDto[]): { task: TaskDto; child: boolean }[] {
const byId = new Set(tasks.map((t) => t.id))
const rows: { task: TaskDto; child: boolean }[] = []
for (const template of tasks.filter((t) => !t.agent_id)) {
rows.push({ task: template, child: false })
for (const over of tasks.filter((t) => t.base_task_id === template.id)) rows.push({ task: over, child: true })
}
// 模板已经不在的覆盖和专属任务
for (const t of tasks.filter((t) => t.agent_id && !(t.base_task_id && byId.has(t.base_task_id)))) rows.push({ task: t, child: false })
return rows
}
/** 作用范围:模板(所有 Agent)、覆盖(某个 Agent 改某个模板)、专属(只属于一个 Agent)。 */
function ScopeCell({ task, agentName }: { task: TaskDto; agentName: (id: string | null) => string }) {
const mode = modeOf(task)
return (
<span className="row" style={{ gap: 6 }}>
{mode === 'template' ? <Chip tone="accent">模板</Chip> : mode === 'override' ? <Chip>覆盖</Chip> : <Chip>专属</Chip>}
<span className={mode === 'template' ? 'muted' : undefined}>{agentName(task.agent_id)}</span>
</span>
)
}
interface TaskTableProps {
title: string
hint: string
category: Category
tasks: TaskDto[]
loaded: boolean
agentName: (id: string | null) => string
onEdit: (t: TaskDto) => void
onDelete: (t: TaskDto) => void
onToggle: (t: TaskDto) => void
onAdd: () => void
canAdd: boolean
targets: TargetDto[]
}
/** 一类任务一张表:Agent 之间的看「作用范围」,单个 Agent 的看「Agent」和「目标」。 */
function TaskTable({ title, hint, category, tasks, loaded, agentName, onEdit, onDelete, onToggle, onAdd, canAdd, targets }: TaskTableProps) {
const single = category === 'single'
return (
<section className="card" style={{ padding: '8px 12px' }}>
<div className="row" style={{ alignItems: 'baseline', gap: 8, padding: '4px 0 8px' }}>
<b>{title}</b>
<span className="muted">{hint}</span>
<button type="button" className="btn small right" onClick={onAdd} disabled={!canAdd}>
<Plus size={14} />
添加任务
</button>
</div>
{tasks.length === 0 ? (
<Empty>{loaded ? '没有任务' : '加载中…'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>采集方法</th>
<th>作用范围</th>
{single ? <th>目标</th> : null}
<th>参数</th>
{single ? <th>运行情况</th> : null}
<th>状态</th>
<th>更新</th>
<th />
</tr>
</thead>
<tbody>
{groupRows(tasks).map(({ task: t, child }) => (
<tr key={t.id} className={child ? 'task-child' : undefined}>
<td>{child ? <span className="task-branch">↳</span> : <KindCell id={t.kind} />}</td>
<td>
<ScopeCell task={t} agentName={agentName} />
</td>
{single ? <td className="mono">{t.target ?? <span className="faint">沿用模板</span>}</td> : null}
<td>
<ParamChips params={t.params as Record<string, unknown>} />
</td>
{single ? (
<td>
<RunCell items={targets.filter((x) => x.task_id === t.id)} />
</td>
) : null}
<td>
<button type="button" className="btn small" onClick={() => onToggle(t)}>
{t.enabled ? <Chip tone="ok">已启用</Chip> : <Chip>已停用</Chip>}
</button>
</td>
<td className="muted">{fmtTime(t.updated_at)}</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn small" onClick={() => onEdit(t)}>
<Pencil size={14} />
</button>
<button type="button" className="btn small danger" onClick={() => onDelete(t)}>
<Trash2 size={14} />
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}
export function Tasks() {
const tasks = useQuery(() => api.get<Page<TaskDto>>('/api/admin/tasks'), [])
const kinds = useQuery(() => api.get<Page<KindDto>>('/api/public/catalog/kinds'), [])
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [])
const [filter, setFilter] = useState<ScopeFilter>('all')
const [editing, setEditing] = useState<TaskDto | 'new'>()
// 单个 Agent 任务有没有跑起来:每个 Agent 一个状态,10 秒刷新一次
const targets = useQuery(() => api.get<Page<TargetDto>>('/api/public/targets', { range: '1h' }), [], 10_000)
// 新建时记下是哪一类(从哪张表点的添加),修改时是任务本身
const [editing, setEditing] = useState<TaskDto | Category>()
const [deleting, setDeleting] = useState<TaskDto>()
const { run } = useAction()
const all = tasks.data?.items ?? []
const list = all.filter((t) => filter === 'all' || kindOf(t) === filter)
const between = all.filter((t) => categoryOf(t) === 'between')
const single = all.filter((t) => categoryOf(t) === 'single')
const toggle = async (t: TaskDto) => {
await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled }))
tasks.reload()
}
const agentName = (id: string | null) => (id ? (agents.data?.items.find((a) => a.id === id)?.name ?? id) : '全部 Agent')
return (
@@ -207,95 +394,48 @@ export function Tasks() {
<h1>任务</h1>
<span className="right row">
{tasks.loading && !tasks.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setEditing('new')} disabled={!kinds.data || !agents.data}>
<Plus size={18} />
添加任务
</button>
</span>
</header>
<ErrorNotice error={tasks.error ?? kinds.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'target' as const, label: '目标任务' },
]}
value={filter}
onChange={setFilter}
<TaskTable
title="Agent 之间"
hint="两个 Agent 之间的线路测量:模板对所有线路生效,覆盖只改某个 Agent 的参数"
category="between"
tasks={between}
loaded={Boolean(tasks.data)}
agentName={agentName}
onEdit={setEditing}
onDelete={setDeleting}
onToggle={toggle}
onAdd={() => setEditing('between')}
canAdd={Boolean(kinds.data && agents.data)}
targets={[]}
/>
<TaskTable
title="单个 Agent"
hint="Agent 自己去测一个外部地址:模板对所有 Agent 生效,也可以只给某个 Agent 专属或覆盖"
category="single"
tasks={single}
loaded={Boolean(tasks.data)}
agentName={agentName}
onEdit={setEditing}
onDelete={setDeleting}
onToggle={toggle}
onAdd={() => setEditing('single')}
canAdd={Boolean(kinds.data && agents.data)}
targets={targets.data?.items ?? []}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{tasks.data ? '没有任务' : '加载中…'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>采集方法</th>
<th>类型</th>
<th>作用于</th>
<th>参数</th>
<th>状态</th>
<th>更新</th>
<th />
</tr>
</thead>
<tbody>
{list.map((t) => {
const k = kindOf(t)
return (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.kind}</td>
<td>
<Chip tone={k === 'target' ? 'accent' : ''}>{k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'}</Chip>
</td>
<td>
{agentName(t.agent_id)}
{t.target ? <div className="mono faint">{t.target}</div> : null}
</td>
<td className="mono muted" style={{ maxWidth: 320, overflowWrap: 'anywhere' }}>
{summary(t.params as Record<string, unknown>)}
</td>
<td>
<button
type="button"
className="btn small"
onClick={async () => {
await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled }))
tasks.reload()
}}
>
{t.enabled ? <Chip tone="ok">已启用</Chip> : <Chip>已停用</Chip>}
</button>
</td>
<td className="muted">{fmtTime(t.updated_at)}</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn small" onClick={() => setEditing(t)}>
<Pencil size={14} />
</button>
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{editing && kinds.data && agents.data ? (
<TaskDialog
task={editing === 'new' ? undefined : editing}
task={typeof editing === 'string' ? undefined : editing}
category={typeof editing === 'string' ? editing : categoryOf(editing)}
kinds={kinds.data.items}
agents={agents.data.items}
templates={all.filter((t) => kindOf(t) === 'template')}
templates={all.filter((t) => modeOf(t) === 'template')}
onClose={() => setEditing(undefined)}
onSaved={tasks.reload}
onSaved={() => { tasks.reload(); targets.reload() }}
/>
) : null}
{deleting ? (
+2
View File
@@ -5,6 +5,7 @@ import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Target } from './pages/Target'
import { Host } from './pages/Host'
import { Overview } from './pages/Overview'
import { useBackgroundTint } from './backgroundTint'
@@ -53,6 +54,7 @@ export function App() {
<Route path="/" element={<Overview />} />
<Route path="/line/:id" element={<LineRoute />} />
<Route path="/host/:id" element={<Host />} />
<Route path="/target/:agent/:task" element={<Target />} />
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+2 -2
View File
@@ -20,12 +20,12 @@ type Level = 'ok' | 'warn' | 'bad' | 'none'
const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad')
const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false })
export const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false })
const hhmm = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
/** 时间轴刻度:在整点(或整半点、整几小时)的格子边界上画一条细线并标时间,最多约 6 个;零点那条标日期。 */
function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] {
export function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] {
if (t.length === 0) return []
const interval = [30, 60, 120, 180, 360, 720, 1440].find((m) => (hours * 60) / m <= 6) ?? 1440
const offset = new Date(t[0]).getTimezoneOffset() * 60_000
+199
View File
@@ -0,0 +1,199 @@
import { useRef, useState, type MouseEvent } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { Page, TargetDto } from '@shared/api/types'
import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { TARGET_STATUS, rejectText } from '@shared/targets'
import { Chip } from '@shared/ui'
import { OVERVIEW_RANGES, axisTicks, clock, type OverviewRange } from './LineStrips'
type Level = 'ok' | 'warn' | 'bad' | 'none'
/** 每种方法在卡片上怎么看:用哪个指标画格子、怎么判断好坏,以及要显示的几个数。 */
export interface KindView {
name: string
bar: string
/** 值越大越好(成功率)还是越小越好(丢包) */
higherIsBetter: boolean
figures: { key: string; label: string; fmt: (v: number | null | undefined) => string }[]
}
const ms = (v: number | null | undefined) => fmtMs(v)
const pct = (v: number | null | undefined) => fmtPct(v)
export const KIND_VIEWS: Record<string, KindView> = {
http_request: {
name: 'HTTP 请求',
bar: 'http.success',
higherIsBetter: true,
figures: [
{ key: 'http.success', label: '成功率', fmt: pct },
{ key: 'http.ttfb_ms.p50', label: '首字节 p50', fmt: ms },
{ key: 'http.ttfb_ms.p95', label: 'p95', fmt: ms },
{ key: 'http.total_ms.p95', label: '总耗时 p95', fmt: ms },
],
},
tcp_connect: {
name: 'TCP 建连',
bar: 'connect.success',
higherIsBetter: true,
figures: [
{ key: 'connect.success', label: '成功率', fmt: pct },
{ key: 'connect.tcp_ms.p95', label: 'TCP p95', fmt: ms },
{ key: 'connect.tls_ms.p95', label: 'TLS p95', fmt: ms },
],
},
icmp_ping: {
name: 'ICMP 往返',
bar: 'loss.total',
higherIsBetter: false,
figures: [
{ key: 'rtt.p50', label: 'RTT p50', fmt: ms },
{ key: 'rtt.p95', label: 'p95', fmt: ms },
{ key: 'loss.total', label: '丢包', fmt: pct },
],
},
}
export const levelOf = (view: KindView, v: number | null | undefined): Level => {
if (v === null || v === undefined) return 'none'
if (view.higherIsBetter) return v >= 99 ? 'ok' : v >= 90 ? 'warn' : 'bad'
return v <= 0.5 ? 'ok' : v < 3 ? 'warn' : 'bad'
}
/** 失败的原因分布:只列出出现过的。 */
export const FAILURES: Record<string, string> = {
'http.fail.timeout': '超时',
'http.fail.dns': 'DNS 失败',
'http.fail.rst': '连接被重置或拒绝',
'http.fail.tls': 'TLS 失败',
'http.fail.status': '状态码不合格',
'http.fail.unsupported': '不支持所选 HTTP 版本(HTTP/3 需要系统装有 MsQuic,且服务器支持)',
'http.fail.other': '其他错误',
}
/** 悬浮面板里宽度有限,用短名字;完整说明在详情页。 */
const SHORT_FAILURES: Record<string, string> = {
'http.fail.timeout': '超时',
'http.fail.dns': 'DNS 失败',
'http.fail.rst': '被重置或拒绝',
'http.fail.tls': 'TLS 失败',
'http.fail.status': '状态码不合格',
'http.fail.unsupported': '版本不支持',
'http.fail.other': '其他错误',
}
function TargetCard({ target: t, range }: { target: TargetDto; range: (typeof OVERVIEW_RANGES)[number] }) {
const view = KIND_VIEWS[t.kind] ?? { name: t.kind, bar: '', higherIsBetter: true, figures: [] }
const status = TARGET_STATUS[t.status]
const bar = t.values[view.bar] ?? []
const cells = bar.length || 36
const ticks = axisTicks(t.t, range.stepMin, range.hours)
// 悬停的那一格:和线路卡片一样,按鼠标横向位置对应到采样格,弹出这一格的数值
const [hover, setHover] = useState<number | null>(null)
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
if (hover !== null) last.current = hover
const shown = hover ?? last.current
const box = useRef<HTMLAnchorElement>(null)
const onMove = (e: MouseEvent<HTMLAnchorElement>) => {
const bars = box.current?.querySelector('.bars')
if (!bars || t.t.length === 0) return
const r = bars.getBoundingClientRect()
setHover(Math.min(t.t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.t.length))))
}
const at = shown !== null && t.t[shown] !== undefined ? shown : null
const cellValue = (key: string, i: number) => t.values[key]?.[i]
return (
<Link to={`/target/${encodeURIComponent(t.agent_id)}/${encodeURIComponent(t.task_id)}?range=${range.value}`} className="card target-card" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)} aria-label={`查看 ${t.agent_name} 对 ${t.target} 的详情`}>
<div className="target-head">
<b>{view.name}</b>
<span className="muted target-agent">{t.agent_name}</span>
<span className="right">
<Chip tone={status.tone}>{status.label}</Chip>
</span>
</div>
<div className="mono target-url" title={t.target}>
{t.target}
</div>
{t.status === 'rejected' ? <p className="muted target-note">{rejectText(t.reason)}</p> : null}
{t.status === 'waiting' || t.status === 'stale' ? <p className="muted target-note">{status.hint}</p> : null}
{t.last_sample && t.status !== 'rejected' ? <p className="faint target-note">最后一次数据:{fmtAgo(t.last_sample)}</p> : null}
{view.figures.length > 0 && t.status !== 'rejected' ? (
<div className="target-figures">
{view.figures.map((f) => (
<span key={f.key}>
<span className="faint">{f.label}</span> <b className="num">{f.fmt(t.summary.metrics[f.key])}</b>
</span>
))}
</div>
) : null}
{view.bar && t.status !== 'rejected' ? (
<div className="target-bars">
<div className="bars" style={{ ['--cells' as string]: cells }} role="img" aria-label={`${view.name},${range.label}`}>
{(bar.length ? bar : Array.from({ length: cells }, () => null)).map((v, i) => (
<span key={t.t[i] ?? i} className={`bar ${levelOf(view, v)}${hover === i ? ' on' : ''}`} />
))}
</div>
{ticks.length > 0 ? (
<div className="line-mini-axis" aria-hidden="true">
{ticks.map((tick) => (
<span key={tick.pos} style={{ left: `${tick.pos}%` }}>
{tick.label}
</span>
))}
</div>
) : null}
</div>
) : null}
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
<>
<b className="pop-time">
{clock(t.t[at], range.hours >= 24)}–{clock(t.t[at] + range.stepMin * 60_000, range.hours >= 24)}
</b>
<div className="pop-group">
<span className="pop-name">{view.name}</span>
{view.figures.map((f) => (
<span key={f.key}>
{f.label} <em className={f.key === view.bar ? levelOf(view, cellValue(f.key, at)) : ''}>{f.fmt(cellValue(f.key, at))}</em>
</span>
))}
{Object.entries(FAILURES)
.filter(([key]) => (cellValue(key, at) ?? 0) > 0)
.map(([key, label]) => (
<span key={key} className="pop-fail">
<span>{SHORT_FAILURES[key] ?? label}</span>
<em className="bad">{cellValue(key, at)}</em>
</span>
))}
</div>
</>
) : null}
</div>
</Link>
)
}
/** 单个 Agent 任务(Agent 自己测外部地址)的卡片:目标、状态、关键数字和一排质量格子。没有这类任务时不显示。 */
export function TargetCards({ range }: { range: OverviewRange }) {
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const targets = useQuery(() => api.get<Page<TargetDto>>('/api/public/targets', { range: info.value, step: info.step }), [info.value], 15_000)
const items = targets.data?.items ?? []
if (items.length === 0) return null
return (
<section className="card agent-card targets-panel">
<div className="agent-head">
<span className="agent-name">外部目标</span>
<span className="faint">
{items.length} 个 · {info.label}
</span>
</div>
<div className="target-grid">
{items.map((t) => (
<TargetCard key={`${t.agent_id}/${t.task_id}`} target={t} range={info} />
))}
</div>
</section>
)
}
+90
View File
@@ -2588,3 +2588,93 @@ input {
0 0 0 2px var(--app-primary),
0 0 0 4px color-mix(in srgb, var(--app-primary) 25%, transparent);
}
.targets-panel {
margin-top: 16px;
}
.target-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
gap: 12px;
margin-top: 12px;
}
.target-card {
display: grid;
gap: 6px;
padding: 12px 14px;
}
.target-head {
display: flex;
align-items: center;
gap: 8px;
}
.target-agent {
font-size: 13px;
}
.target-url {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-note {
margin: 0;
font-size: 12px;
}
.target-figures {
display: flex;
flex-wrap: wrap;
gap: 2px 14px;
font-size: 13px;
}
.bad-text {
color: var(--app-bad);
}
.target-bars {
display: grid;
gap: 3px;
}
/* 时间轴原本是线路卡片网格里的第 2 列,这里是普通的一行 */
.target-bars .line-mini-axis {
grid-column: auto;
grid-row: auto;
}
.target-card {
position: relative;
}
.target-card:has(.pop.open) {
z-index: 30;
}
/* 整张卡片是链接:悬停时也不要下划线(全局的 a:hover 会加) */
.target-card,
.target-card:hover {
color: inherit;
text-decoration: none;
}
/* 弹出面板是固定宽度、不换行的;失败原因的名字可能很长,允许它换行,次数靠右 */
.pop .pop-fail {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
white-space: normal;
}
/* 「这一格还没走完」这类整句提示:面板不换行,长句会冲出去,这里允许换行 */
.pop .pop-note {
white-space: normal;
}
+1 -1
View File
@@ -349,7 +349,7 @@ export function Line() {
))}
</div> : null}
<div className="line-data-notes">
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['quic_ping'])} · TCP {fmtNum(s.summary.samples['tcp_ping'])}</span>
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
</div>
{chartSections}
+3
View File
@@ -11,6 +11,7 @@ import { OsIcon } from '@shared/osIcon'
import { PageError } from '../lib'
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
import { TargetCards } from '../TargetCards'
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans', 'tcp.retrans.up', 'tcp.retrans.down'].join(',')
@@ -381,6 +382,8 @@ export function Overview() {
)
) : null}
</section>
<TargetCards range={range} />
</main>
)
}
+169
View File
@@ -0,0 +1,169 @@
import { ChevronLeft } from 'lucide-react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { Page, TargetDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtAgo, fmtNum, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { TARGET_STATUS, rejectText } from '@shared/targets'
import { Chip, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
import { PageError } from '../lib'
import { OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
import { FAILURES, KIND_VIEWS, levelOf } from '../TargetCards'
interface ChartDef {
id: string
title: string
unit: string
/** 失败计数这类图:只在有过失败时显示 */
onlyIfAny?: boolean
series: { key: string; label: string; color: string; dash?: number[] }[]
}
/** 每种方法的图:卡片上只有一排格子,这里把每个指标画成曲线。 */
const CHARTS: Record<string, ChartDef[]> = {
http_request: [
{ id: 'success', title: '成功率', unit: '%', series: [{ key: 'http.success', label: '成功率', color: '--chart-1' }] },
{ id: 'ttfb', title: '首字节时间', unit: 'ms', series: [{ key: 'http.ttfb_ms.p50', label: 'p50', color: '--chart-1' }, { key: 'http.ttfb_ms.p95', label: 'p95', color: '--chart-2' }, { key: 'http.ttfb_ms.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'total', title: '总耗时', unit: 'ms', series: [{ key: 'http.total_ms.p50', label: 'p50', color: '--chart-1' }, { key: 'http.total_ms.p95', label: 'p95', color: '--chart-2' }, { key: 'http.total_ms.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'phases', title: 'DNS 和 TCP 建连(p95)', unit: 'ms', series: [{ key: 'http.dns_ms.p95', label: 'DNS', color: '--chart-1' }, { key: 'http.tcp_ms.p95', label: 'TCP 建连', color: '--chart-2' }] },
{
id: 'fails',
title: '失败次数',
unit: '',
onlyIfAny: true,
series: Object.keys(FAILURES).map((key, i) => ({ key, label: FAILURES[key].split('(')[0], color: `--chart-${(i % 5) + 1}` })),
},
],
tcp_connect: [
{ id: 'success', title: '成功率', unit: '%', series: [{ key: 'connect.success', label: '成功率', color: '--chart-1' }] },
{ id: 'phases', title: '建连耗时(p95)', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS', color: '--chart-2' }] },
{
id: 'fails',
title: '失败次数',
unit: '',
onlyIfAny: true,
series: [{ key: 'connect.fail.timeout', label: '超时', color: '--chart-1' }, { key: 'connect.fail.rst', label: '被重置或拒绝', color: '--chart-2' }, { key: 'connect.fail.tls', label: 'TLS 失败', color: '--chart-3' }],
},
],
icmp_ping: [
{ id: 'rtt', title: '往返时延', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'loss', title: '丢包', unit: '%', series: [{ key: 'loss.total', label: '丢包', color: '--chart-1' }] },
],
}
/** 单个 Agent 任务的详情:一个 Agent 对一个外部目标的状态、关键数字、各指标曲线和失败原因。 */
export function Target() {
const { agent = '', task = '' } = useParams()
const [params, setParams] = useSearchParams()
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const query = useQuery(
() => api.get<Page<TargetDto>>('/api/public/targets', { agent_id: agent, task_id: task, full: 'true', range: info.value, step: info.step }),
[agent, task, info.value],
15_000,
)
const t = query.data?.items[0]
const view = t ? (KIND_VIEWS[t.kind] ?? { name: t.kind, bar: '', higherIsBetter: true, figures: [] }) : null
const status = t ? TARGET_STATUS[t.status] : null
const charts = t ? (CHARTS[t.kind] ?? []) : []
const metrics = t?.summary.metrics ?? {}
const failures = Object.entries(FAILURES).filter(([key]) => (metrics[key] ?? 0) > 0)
return (
<main className="page line-page target-page">
<div className="crumbs">
<Link to="/" className="row">
<ChevronLeft size={16} />
总览
</Link>
</div>
<PageError error={query.error} />
<section className="hero">
<h1>{view ? view.name : '外部目标'}</h1>
{t ? (
<p className="sub">
<span className="mono">{t.target}</span> · {t.agent_name}
{status ? (
<>
{' '}
<Chip tone={status.tone}>{status.label}</Chip>
</>
) : null}
{t.last_sample ? <span className="faint"> · 最后一次数据 {fmtAgo(t.last_sample)}({fmtTime(t.last_sample)})</span> : null}
</p>
) : query.loading ? (
<p className="sub">
<Spinner />
</p>
) : (
<p className="sub">没有找到这个目标:任务可能已被删除,或这个 Agent 不再执行它。</p>
)}
</section>
{t?.status === 'rejected' ? <p className="muted">{rejectText(t.reason)}</p> : null}
{t && view ? (
<>
<div className="toolbar">
<Segmented<OverviewRange>
options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))}
value={range}
onChange={(v) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (v === '3h') next.delete('range')
else next.set('range', v)
return next
},
{ replace: true },
)
}
/>
</div>
<div className="card">
<div className="grid stats">
{view.figures.map((f) => (
<Stat key={f.key} label={f.label} value={f.fmt(metrics[f.key])} tone={f.key === view.bar ? (({ ok: 'ok', warn: 'warn', bad: 'bad', none: '' }) as const)[levelOf(view, metrics[f.key])] : ''} />
))}
{Object.entries(t.summary.samples)[0] ? <Stat label="请求数" value={fmtNum(Object.values(t.summary.samples)[0])} /> : null}
</div>
</div>
{failures.length > 0 ? (
<section className="card target-failures">
<h3>失败原因({info.label})</h3>
{failures.map(([key, label]) => (
<p key={key}>
{label}:<b className="bad-text">{metrics[key]} 次</b>
</p>
))}
</section>
) : null}
<section className="chart-section">
<div className="grid cols-2">
{charts
.map((def) => {
const series: ChartSeries[] = def.series.map((x) => ({ label: x.label, values: t.values[x.key] ?? [], color: x.color, dash: x.dash }))
return { def, series }
})
.filter(({ def, series }) => series.some((x) => x.values.some((v) => v !== null && (!def.onlyIfAny || v > 0))))
.map(({ def, series }) => (
<div className="card chart-card has-loading" key={def.id}>
<LoadingBar active={query.loading} />
<h3>{def.title}</h3>
<TimeChart t={t.t} series={series} unit={def.unit} zero height={200} />
</div>
))}
</div>
</section>
</>
) : null}
</main>
)
}
+17
View File
@@ -69,3 +69,20 @@ export interface Page<T> {
/** 后端数字有时按 integer|string 声明:统一成 number。 */
export const n = (v: number | string | null | undefined): number => (v === null || v === undefined ? 0 : Number(v))
export interface TargetDto {
task_id: string
agent_id: string
agent_name: string
kind: string
target: string
status: 'running' | 'waiting' | 'stale' | 'rejected' | 'offline'
reason?: string | null
last_sample?: string | null
from: string
to: string
step: string
t: number[]
values: Record<string, (number | null)[]>
summary: { metrics: Record<string, number | null>; samples: Record<string, number>; coverage: number }
}
+20
View File
@@ -0,0 +1,20 @@
import type { TargetDto } from './api/types'
/** 单个 Agent 任务的状态:文字、颜色和一句说明。 */
export const TARGET_STATUS: Record<TargetDto['status'], { label: string; tone: 'ok' | 'warn' | 'bad' | ''; hint: string }> = {
running: { label: '运行中', tone: 'ok', hint: '最近 15 分钟内有数据' },
waiting: { label: '等待数据', tone: 'warn', hint: '任务已下发,Agent 还没上传第一批数据(每 10 秒一批)' },
stale: { label: '数据中断', tone: 'warn', hint: '以前有数据,最近 15 分钟没有新数据' },
rejected: { label: '被拒绝', tone: 'bad', hint: 'Agent 本机的限制不允许执行这个任务' },
offline: { label: 'Agent 离线', tone: '', hint: 'Agent 不在线,任务会在它回来后继续' },
}
/** Agent 拒绝任务的原因,翻成人话。 */
export const REJECT_REASONS: Record<string, string> = {
policy_denied: '本机不允许这个目标:把它所在的网段加进 agent.json 的 limits.allowed_target_cidrs',
budget_exceeded: '超出本机的速率预算',
unsupported: '这个 Agent 不支持这种采集方法',
invalid_params: '参数不合法',
}
export const rejectText = (reason?: string | null) => (reason ? (REJECT_REASONS[reason] ?? reason) : '')