feat(quic): replace probe UDP data plane with QUIC datagrams

This commit is contained in:
justme committed 2026-10-10 02:59:56 +08:00
1 parent 4609048865
commit b964dc2bbc
53 files changed
+2413 -411

No files matched your search

@@ -26,10 +26,18 @@ public static class LinkMetrics
"jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
public static readonly MetricDef LossRun = Metric.RunLength(
"loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
"loss.run", s_udpMsg, Ops.LossRuns(Dir.Down), "Loss runs (legacy downstream alias)", "Legacy downstream-only consecutive loss metric.",
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
public static readonly MetricDef LossRunUp = Metric.Max(
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.",
Unit.Packets, "loss.run.up_max", good: 0, poor: 10);
public static readonly MetricDef LossRunDown = Metric.Max(
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
Unit.Packets, "loss.run.down_max", good: 0, poor: 10);
public static readonly MetricDef Reorder = Metric.Counter(
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
@@ -11,7 +11,7 @@ public static class MetricRegistry
public static readonly IReadOnlyList<MetricDef> All =
[
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
@@ -22,7 +22,8 @@ public static class MetricRegistry
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
public static readonly IReadOnlyList<PresetDef> Presets =
[
new("udp", "UDP", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
new("quic", "QUIC Datagram", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run.up_max", "loss.run.down_max", "reorder", "dup"]),
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.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("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
@@ -1,6 +1,6 @@
namespace MoShiro.Backend.Analysis;
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
public static class SessionMetrics
{
private static readonly string[] s_conn = ["connect.conn"];
@@ -33,13 +33,13 @@ public static class SessionMetrics
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
public static readonly MetricDef TrainLoss = Metric.Ratio(
"train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
"train.loss", s_train, Ops.TrainLoss, "Datagram train loss", "Loss of a fixed-rate QUIC Datagram stream.", good: 1, poor: 10);
public static readonly MetricDef TrainJitter = Metric.Histogram(
"train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
"train.jitter", s_train, Ops.TrainJitter, "Datagram train delay variation", "One-way delay variation of a fixed-rate QUIC Datagram stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
public static readonly MetricDef TrainRate = Metric.Histogram(
"train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
"train.rate", s_train, Ops.TrainRate, "Datagram train rate", "Delivered rate of a fixed-rate QUIC Datagram stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
public static readonly MetricDef SamplesConnect = Metric.Counter(
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
@@ -12,9 +12,9 @@ public sealed class BaseModel : ServiceModelBase
public override string Title => "Line quality";
public override int Version => 1;
public override int Version => 2;
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run.up_max", "loss.run.down_max"];
public override ModelScore Score(MetricView view) =>
Combine(
@@ -24,7 +24,8 @@ public sealed class BaseModel : ServiceModelBase
Dim(view, "loss.total", "Packet loss", 4, s_loss),
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
Dim(view, "loss.run.up_max", "Longest upstream loss run", 0.5, s_run),
Dim(view, "loss.run.down_max", "Longest downstream loss run", 0.5, s_run),
],
estimated: false);
}
@@ -273,7 +273,14 @@ internal static class JitterEmitter
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
internal sealed class LossRunsOp : MetricOp
{
public override string Signature => "loss_runs/1";
private readonly Dir _direction;
public LossRunsOp(Dir direction)
{
_direction = direction;
}
public override string Signature => $"loss_runs/{_direction}/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
@@ -292,7 +299,13 @@ internal sealed class LossRunsOp : MetricOp
continue;
}
if (c.IsReplyLost(i))
bool lost = _direction switch
{
Dir.Up => c.IsUpLost(i),
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
_ => c.IsReplyLost(i),
};
if (lost)
{
if (run == 0)
{
@@ -315,6 +328,59 @@ internal sealed class LossRunsOp : MetricOp
}
}
/// <summary>每个块一个最长连续丢包值;无丢包的有效块明确输出 0。</summary>
internal sealed class LossRunMaxOp(Dir direction) : MetricOp
{
public override string Signature => $"loss_run_max/{direction}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new EchoColumns(stream);
if (!c.HasBasics || (direction != Dir.Total && c.T2 is null))
{
return;
}
int run = 0;
int maximum = 0;
bool hasSample = false;
long sampleAt = 0;
for (int i = 0; i < c.Count; i++)
{
if (!c.T1!.IsValid(i))
{
continue;
}
bool lost = direction switch
{
Dir.Up => c.IsUpLost(i),
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
_ => c.IsReplyLost(i),
};
if (direction == Dir.Down && c.IsUpLost(i))
{
// A request lost before reaching the Agent is not a downstream sample.
run = 0;
continue;
}
if (!hasSample)
{
sampleAt = c.SentUtcMs(view, i);
hasSample = true;
}
run = lost ? run + 1 : 0;
maximum = Math.Max(maximum, run);
}
if (hasSample)
{
sink.Emit(sampleAt, maximum);
}
}
}
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
internal sealed class ReorderOp : MetricOp
{
@@ -11,7 +11,9 @@ public static class Ops
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
public static MetricOp LossRuns { get; } = new LossRunsOp();
public static MetricOp LossRuns(Dir direction) => new LossRunsOp(direction);
public static MetricOp LossRunMax(Dir direction) => new LossRunMaxOp(direction);
public static MetricOp Reorder { get; } = new ReorderOp();
@@ -31,7 +31,23 @@ public static class Mappers
public static OverviewLineDto ToDto(OverviewLine line) =>
new(
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.QuicHealth));
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
{
if (snapshot is null)
{
return null;
}
ProbeQuicLinkHealth health = snapshot.Health;
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null;
return new QuicLineHealthDto(
health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts,
Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount,
health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents,
health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs));
}
public static HostDto ToDto(HostSnapshot h) =>
new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs));
@@ -102,7 +118,7 @@ public static class Mappers
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
@@ -42,6 +42,7 @@ public sealed record AgentDto(
string PublicHost,
int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts,
IReadOnlyList<int> ProbeQuicPorts,
IReadOnlyList<string> Labels,
bool PublicVisible,
IReadOnlyList<string> Kinds,
@@ -22,6 +22,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(DistributionDto))]
[JsonSerializable(typeof(HeatmapDto))]
[JsonSerializable(typeof(LineDto))]
[JsonSerializable(typeof(QuicLineHealthDto))]
[JsonSerializable(typeof(TestDto))]
[JsonSerializable(typeof(PageDto<TestDto>))]
[JsonSerializable(typeof(PageDto<KindDto>))]
@@ -28,7 +28,27 @@ public sealed record OverviewLineDto(
int EndpointChanges,
SummaryDto Summary,
TestSummaryDto? LastTest,
string IpVersion = "ipv4");
string IpVersion = "ipv4",
QuicLineHealthDto? QuicHealth = null);
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
public sealed record QuicLineHealthDto(
bool Connected,
long HandshakeAttempts,
long HandshakeSuccesses,
long HandshakeFailures,
long HandshakeTimeouts,
double? HandshakeSuccessRate,
double? HandshakeFailureRate,
double? HandshakeTimeoutRate,
double? HandshakeP95Ms,
long HandshakeSampleCount,
double? TransportPacketLossRate,
ulong? TransportPacketsSent,
uint? TransportCongestionEvents,
uint? TransportEcnCongestionEvents,
ushort? PathMtu,
DateTimeOffset At);
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
@@ -90,7 +110,8 @@ public sealed record LineDto(
IReadOnlyList<string> Kinds,
TestSummaryDto? LastTest,
string IpVersion = "ipv4",
bool Ipv6Available = false);
bool Ipv6Available = false,
QuicLineHealthDto? QuicHealth = null);
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Api;
public static class QueryEndpoints
{
private const string Tag = "public";
private const string DefaultOverviewPreset = "udp,tcp,scores";
private const string DefaultSeriesPreset = "udp,tcp";
private const string DefaultOverviewPreset = "quic,tcp,scores";
private const string DefaultSeriesPreset = "quic,tcp";
public static void Map(RouteGroupBuilder group)
{
@@ -102,7 +102,7 @@ public static class QueryEndpoints
return TypedResults.Json(
new LineDto(
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0),
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
ApiJsonContext.Default.LineDto);
}
@@ -173,7 +173,8 @@ public sealed partial class AgentService
Host = agent.PublicHost,
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
UdpPorts = [.. agent.ProbeUdpPorts],
QuicPorts = [.. agent.ProbeUdpPorts],
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
});
}
@@ -43,7 +43,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct);
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
{
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
@@ -70,10 +70,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
private readonly ProbeQuicRuntime _quicRuntime;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime)
{
_owners = owners;
_quicRuntime = quicRuntime;
_releases = releases;
_schedule = schedule;
_probes = probes;
@@ -168,12 +170,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
case NodeEventKind.Offline:
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
_releases.RecordStatus(id, null);
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
break;
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
try
{
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
_releases.RecordStatus(id, heartbeat?.Update);
if (heartbeat?.QuicLinks is { } quicLinks)
{
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
}
if (heartbeat?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
@@ -236,7 +243,8 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
IReadOnlyList<int>? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts;
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs);
/// <summary>Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics.</summary>
public sealed class ProbeQuicRuntime
{
private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new();
public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) =>
_links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null;
public void Replace(string probeId, long atMs, IReadOnlyList<ProbeQuicLinkHealth> links)
{
var current = new HashSet<(string Probe, string Agent, string IpVersion)>();
foreach (ProbeQuicLinkHealth link in links)
{
var key = (probeId, link.AgentId, link.IpVersion);
current.Add(key);
_links[key] = new ProbeQuicLinkSnapshot(link, atMs);
}
foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k)))
{
_links.TryRemove(key, out _);
}
}
public void MarkProbeOffline(string probeId, long atMs)
{
foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId))
{
ProbeQuicLinkHealth old = pair.Value.Health;
_links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth
{
AgentId = old.AgentId,
IpVersion = old.IpVersion,
Connected = false,
HandshakeAttempts = old.HandshakeAttempts,
HandshakeSuccesses = old.HandshakeSuccesses,
HandshakeFailures = old.HandshakeFailures,
HandshakeTimeouts = old.HandshakeTimeouts,
HandshakeP95Ms = old.HandshakeP95Ms,
HandshakeSampleCount = old.HandshakeSampleCount,
TransportPacketLossRate = old.TransportPacketLossRate,
TransportPacketsSent = old.TransportPacketsSent,
TransportCongestionEvents = old.TransportCongestionEvents,
TransportEcnCongestionEvents = old.TransportEcnCongestionEvents,
PathMtu = old.PathMtu,
}, atMs);
}
}
}
@@ -16,6 +16,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddHostedService<StartupTasks>();
services.AddSingleton<AgentRuntime>();
services.AddSingleton<ProbeQuicRuntime>();
services.AddSingleton<LiveHub>();
services.AddSingleton<HotLayer>();
services.AddSingleton<BackendCounters>();
@@ -25,7 +25,7 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
}
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4)
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null)
{
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
@@ -40,12 +40,14 @@ public sealed class LineService
private readonly AgentService _agents;
private readonly IProbeStore _probes;
private readonly LinkService _links;
private readonly ProbeQuicRuntime _quicRuntime;
public LineService(AgentService agents, IProbeStore probes, LinkService links)
public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime)
{
_links = links;
_agents = agents;
_probes = probes;
_quicRuntime = quicRuntime;
}
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
@@ -59,10 +61,10 @@ public sealed class LineService
{
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
{
result.Add(new LineView(agent, probe));
result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4)));
if (agent.Agent.Ipv6Host.Length > 0)
{
result.Add(new LineView(agent, probe, IpVersions.V6));
result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6)));
}
}
}
@@ -117,6 +119,6 @@ public sealed class LineService
probe = active[0];
}
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion));
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion)));
}
}
@@ -240,7 +240,7 @@ public sealed class TestService
}
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
{
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
}