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