chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,241 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
public enum AccKind : byte
|
||||
{
|
||||
Counter = 1,
|
||||
Ratio = 2,
|
||||
Sum = 3,
|
||||
Min = 4,
|
||||
Max = 5,
|
||||
Histogram = 6,
|
||||
RunLength = 7,
|
||||
Series = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有指标的中间状态只能是下面几种累加器之一。合并规则只写一次,
|
||||
/// 存储、降采样、按时间范围聚合都通用处理。
|
||||
/// </summary>
|
||||
public abstract class Accumulator
|
||||
{
|
||||
public abstract AccKind Kind { get; }
|
||||
|
||||
/// <summary>加入一个观测值。含义由累加器决定:Counter 加 <paramref name="a"/>,Ratio 的 (a, b) 是 (分子, 分母),Histogram 记录 a。</summary>
|
||||
public abstract void Add(double a, double b = 0);
|
||||
|
||||
/// <summary>合并同类型的另一个累加器(精确合并,不是平均)。</summary>
|
||||
public abstract void Merge(Accumulator other);
|
||||
|
||||
public abstract Accumulator Clone();
|
||||
|
||||
/// <summary>样本数,用于置信说明。</summary>
|
||||
public abstract ulong SampleCount { get; }
|
||||
|
||||
public static Accumulator Create(AccKind kind) => kind switch
|
||||
{
|
||||
AccKind.Counter => new CounterAcc(),
|
||||
AccKind.Ratio => new RatioAcc(),
|
||||
AccKind.Sum => new SumAcc(),
|
||||
AccKind.Min => new MinAcc(),
|
||||
AccKind.Max => new MaxAcc(),
|
||||
AccKind.Histogram => new HistogramAcc(),
|
||||
AccKind.RunLength => new RunLengthAcc(),
|
||||
AccKind.Series => new SeriesAcc(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
protected T Cast<T>(Accumulator other)
|
||||
where T : Accumulator =>
|
||||
other as T ?? throw new InvalidOperationException($"cannot merge {other.Kind} into {Kind}");
|
||||
}
|
||||
|
||||
public sealed class CounterAcc : Accumulator
|
||||
{
|
||||
public ulong Value { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Counter;
|
||||
|
||||
public override ulong SampleCount => Value;
|
||||
|
||||
public override void Add(double a, double b = 0) => Value += (ulong)Math.Max(0, a);
|
||||
|
||||
public override void Merge(Accumulator other) => Value += Cast<CounterAcc>(other).Value;
|
||||
|
||||
public override Accumulator Clone() => new CounterAcc { Value = Value };
|
||||
}
|
||||
|
||||
public sealed class RatioAcc : Accumulator
|
||||
{
|
||||
public ulong Numerator { get; set; }
|
||||
|
||||
public ulong Denominator { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Ratio;
|
||||
|
||||
public override ulong SampleCount => Denominator;
|
||||
|
||||
/// <summary>百分比(0–100);没有样本时为 null。</summary>
|
||||
public double? Percent => Denominator == 0 ? null : 100.0 * Numerator / Denominator;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Numerator += (ulong)Math.Max(0, a);
|
||||
Denominator += (ulong)Math.Max(0, b);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<RatioAcc>(other);
|
||||
Numerator += o.Numerator;
|
||||
Denominator += o.Denominator;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new RatioAcc { Numerator = Numerator, Denominator = Denominator };
|
||||
}
|
||||
|
||||
public sealed class SumAcc : Accumulator
|
||||
{
|
||||
public double Sum { get; set; }
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Sum;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Mean => Count == 0 ? null : Sum / Count;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Sum += a;
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<SumAcc>(other);
|
||||
Sum += o.Sum;
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new SumAcc { Sum = Sum, Count = Count };
|
||||
}
|
||||
|
||||
public sealed class MinAcc : Accumulator
|
||||
{
|
||||
public double Value { get; set; } = double.PositiveInfinity;
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Min;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Result => Count == 0 ? null : Value;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Value = Math.Min(Value, a);
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<MinAcc>(other);
|
||||
Value = Math.Min(Value, o.Value);
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new MinAcc { Value = Value, Count = Count };
|
||||
}
|
||||
|
||||
public sealed class MaxAcc : Accumulator
|
||||
{
|
||||
public double Value { get; set; } = double.NegativeInfinity;
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Max;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Result => Count == 0 ? null : Value;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Value = Math.Max(Value, a);
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<MaxAcc>(other);
|
||||
Value = Math.Max(Value, o.Value);
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new MaxAcc { Value = Value, Count = Count };
|
||||
}
|
||||
|
||||
/// <summary>连续段长度(例如连续丢包):只保留次数、总和和最大值,合并是精确的。</summary>
|
||||
public sealed class RunLengthAcc : Accumulator
|
||||
{
|
||||
public ulong Runs { get; set; }
|
||||
|
||||
public double Total { get; set; }
|
||||
|
||||
public double Max { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.RunLength;
|
||||
|
||||
public override ulong SampleCount => Runs;
|
||||
|
||||
public double? Mean => Runs == 0 ? null : Total / Runs;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Runs++;
|
||||
Total += a;
|
||||
Max = Math.Max(Max, a);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<RunLengthAcc>(other);
|
||||
Runs += o.Runs;
|
||||
Total += o.Total;
|
||||
Max = Math.Max(Max, o.Max);
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new RunLengthAcc { Runs = Runs, Total = Total, Max = Max };
|
||||
}
|
||||
|
||||
/// <summary>固定分辨率的时间序列(仅用于测试结果):Add(a = 序号, b = 值),合并为拼接。</summary>
|
||||
public sealed class SeriesAcc : Accumulator
|
||||
{
|
||||
public List<double> Points { get; } = [];
|
||||
|
||||
public override AccKind Kind => AccKind.Series;
|
||||
|
||||
public override ulong SampleCount => (ulong)Points.Count;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
int index = (int)a;
|
||||
while (Points.Count <= index)
|
||||
{
|
||||
Points.Add(double.NaN);
|
||||
}
|
||||
|
||||
Points[index] = b;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other) => Points.AddRange(Cast<SeriesAcc>(other).Points);
|
||||
|
||||
public override Accumulator Clone()
|
||||
{
|
||||
var copy = new SeriesAcc();
|
||||
copy.Points.AddRange(Points);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>一个时间桶里所有指标的累加器:<c>指标 id → 累加器</c>。</summary>
|
||||
public sealed class MetricSet
|
||||
{
|
||||
private readonly Dictionary<string, Accumulator> _items = new(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyDictionary<string, Accumulator> Items => _items;
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public Accumulator GetOrAdd(string metricId, AccKind kind)
|
||||
{
|
||||
if (!_items.TryGetValue(metricId, out Accumulator? acc))
|
||||
{
|
||||
acc = Accumulator.Create(kind);
|
||||
_items[metricId] = acc;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
public bool TryGet(string metricId, out Accumulator acc) => _items.TryGetValue(metricId, out acc!);
|
||||
|
||||
/// <summary>逐个指标精确合并。</summary>
|
||||
public void Merge(MetricSet other)
|
||||
{
|
||||
foreach ((string id, Accumulator acc) in other._items)
|
||||
{
|
||||
if (_items.TryGetValue(id, out Accumulator? mine))
|
||||
{
|
||||
if (mine.Kind == acc.Kind)
|
||||
{
|
||||
mine.Merge(acc);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_items[id] = acc.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string metricId, Accumulator acc) => _items[metricId] = acc;
|
||||
|
||||
public MetricSet Clone()
|
||||
{
|
||||
var copy = new MetricSet();
|
||||
foreach ((string id, Accumulator acc) in _items)
|
||||
{
|
||||
copy._items[id] = acc.Clone();
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 累加器的二进制编码:存储层只把它当作不透明的字节。
|
||||
/// <c>varint 数量 | { varint id 长度 | id | u8 kind | varint 长度 | 状态 }…</c>
|
||||
/// </summary>
|
||||
public static class AccumulatorCodec
|
||||
{
|
||||
public static byte[] Encode(MetricSet set)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
VarInt.Write(stream, (ulong)set.Count);
|
||||
foreach ((string id, Accumulator acc) in set.Items.OrderBy(i => i.Key, StringComparer.Ordinal))
|
||||
{
|
||||
byte[] idBytes = Encoding.UTF8.GetBytes(id);
|
||||
VarInt.Write(stream, (ulong)idBytes.Length);
|
||||
stream.Write(idBytes);
|
||||
stream.WriteByte((byte)acc.Kind);
|
||||
byte[] state = EncodeState(acc);
|
||||
VarInt.Write(stream, (ulong)state.Length);
|
||||
stream.Write(state);
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static Result<MetricSet> Decode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var set = new MetricSet();
|
||||
if (!VarInt.TryRead(data, out ulong count, out int used))
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
for (ulong i = 0; i < count; i++)
|
||||
{
|
||||
if (!VarInt.TryRead(data, out ulong idLength, out used) || (ulong)(data.Length - used) < idLength)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
string id = Encoding.UTF8.GetString(data[..(int)idLength]);
|
||||
data = data[(int)idLength..];
|
||||
if (data.Length < 1)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
var kind = (AccKind)data[0];
|
||||
data = data[1..];
|
||||
if (!VarInt.TryRead(data, out ulong stateLength, out used) || (ulong)(data.Length - used) < stateLength)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
Accumulator? acc = DecodeState(kind, data[..(int)stateLength]);
|
||||
if (acc is null)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
set.Set(id, acc);
|
||||
data = data[(int)stateLength..];
|
||||
}
|
||||
|
||||
return Result<MetricSet>.Ok(set);
|
||||
}
|
||||
|
||||
private static Result<MetricSet> Bad() =>
|
||||
Result<MetricSet>.Fail(FleetErrors.Internal, "accumulator data is corrupt");
|
||||
|
||||
public static byte[] EncodeState(Accumulator acc)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
switch (acc)
|
||||
{
|
||||
case CounterAcc c:
|
||||
VarInt.Write(stream, c.Value);
|
||||
break;
|
||||
case RatioAcc r:
|
||||
VarInt.Write(stream, r.Numerator);
|
||||
VarInt.Write(stream, r.Denominator);
|
||||
break;
|
||||
case SumAcc s:
|
||||
WriteDouble(stream, s.Sum);
|
||||
VarInt.Write(stream, s.Count);
|
||||
break;
|
||||
case MinAcc m:
|
||||
WriteDouble(stream, m.Value);
|
||||
VarInt.Write(stream, m.Count);
|
||||
break;
|
||||
case MaxAcc x:
|
||||
WriteDouble(stream, x.Value);
|
||||
VarInt.Write(stream, x.Count);
|
||||
break;
|
||||
case RunLengthAcc rl:
|
||||
VarInt.Write(stream, rl.Runs);
|
||||
WriteDouble(stream, rl.Total);
|
||||
WriteDouble(stream, rl.Max);
|
||||
break;
|
||||
case HistogramAcc h:
|
||||
VarInt.Write(stream, h.Count);
|
||||
WriteDouble(stream, h.Sum);
|
||||
WriteDouble(stream, h.Min);
|
||||
WriteDouble(stream, h.Max);
|
||||
VarInt.Write(stream, (ulong)h.NonEmptyBuckets);
|
||||
int previous = 0;
|
||||
foreach ((int bucket, ulong n) in h.Buckets())
|
||||
{
|
||||
VarInt.Write(stream, (ulong)(bucket - previous));
|
||||
VarInt.Write(stream, n);
|
||||
previous = bucket;
|
||||
}
|
||||
|
||||
break;
|
||||
case SeriesAcc se:
|
||||
VarInt.Write(stream, (ulong)se.Points.Count);
|
||||
foreach (double p in se.Points)
|
||||
{
|
||||
WriteDouble(stream, p);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("unknown accumulator");
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static Accumulator? DecodeState(AccKind kind, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var reader = new Reader(data);
|
||||
switch (kind)
|
||||
{
|
||||
case AccKind.Counter:
|
||||
return reader.ReadVarInt(out ulong v) ? new CounterAcc { Value = v } : null;
|
||||
case AccKind.Ratio:
|
||||
return reader.ReadVarInt(out ulong num) && reader.ReadVarInt(out ulong den)
|
||||
? new RatioAcc { Numerator = num, Denominator = den }
|
||||
: null;
|
||||
case AccKind.Sum:
|
||||
return reader.ReadDouble(out double sum) && reader.ReadVarInt(out ulong sn) ? new SumAcc { Sum = sum, Count = sn } : null;
|
||||
case AccKind.Min:
|
||||
return reader.ReadDouble(out double min) && reader.ReadVarInt(out ulong mn) ? new MinAcc { Value = min, Count = mn } : null;
|
||||
case AccKind.Max:
|
||||
return reader.ReadDouble(out double max) && reader.ReadVarInt(out ulong xn) ? new MaxAcc { Value = max, Count = xn } : null;
|
||||
case AccKind.RunLength:
|
||||
return reader.ReadVarInt(out ulong runs) && reader.ReadDouble(out double total) && reader.ReadDouble(out double rmax)
|
||||
? new RunLengthAcc { Runs = runs, Total = total, Max = rmax }
|
||||
: null;
|
||||
case AccKind.Histogram:
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong count) || !reader.ReadDouble(out double hsum) || !reader.ReadDouble(out double hmin)
|
||||
|| !reader.ReadDouble(out double hmax) || !reader.ReadVarInt(out ulong buckets) || buckets > (ulong)HistogramAcc.BucketCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = new List<(int, ulong)>((int)buckets);
|
||||
int bucket = 0;
|
||||
for (ulong i = 0; i < buckets; i++)
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong delta) || !reader.ReadVarInt(out ulong n))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bucket += (int)delta;
|
||||
if (bucket >= HistogramAcc.BucketCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
list.Add((bucket, n));
|
||||
}
|
||||
|
||||
var hist = new HistogramAcc();
|
||||
hist.Restore(list, count, hsum, hmin, hmax);
|
||||
return hist;
|
||||
}
|
||||
|
||||
case AccKind.Series:
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong n) || n > 10_000_000)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var series = new SeriesAcc();
|
||||
for (ulong i = 0; i < n; i++)
|
||||
{
|
||||
if (!reader.ReadDouble(out double p))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
series.Points.Add(p);
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteDouble(MemoryStream stream, double value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(bytes, value);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
|
||||
private ref struct Reader
|
||||
{
|
||||
private ReadOnlySpan<byte> _data;
|
||||
|
||||
public Reader(ReadOnlySpan<byte> data)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public bool ReadVarInt(out ulong value)
|
||||
{
|
||||
if (!VarInt.TryRead(_data, out value, out int used))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_data = _data[used..];
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ReadDouble(out double value)
|
||||
{
|
||||
if (_data.Length < 8)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadDoubleLittleEndian(_data);
|
||||
_data = _data[8..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 固定对数桶直方图:100 µs ~ 30 s,每 2 倍 8 个桶(相对误差 ≤ 4.5%)。
|
||||
/// 稀疏存储(只保留非空桶),因为 1 秒热层里每个桶只有几个样本。数值单位是毫秒。
|
||||
/// </summary>
|
||||
public sealed class HistogramAcc : Accumulator
|
||||
{
|
||||
public const double MinValueMs = 0.1;
|
||||
public const double MaxValueMs = 30_000;
|
||||
public const int BucketsPerOctave = 8;
|
||||
|
||||
/// <summary>桶 0 是下溢桶 [0, 0.1),桶 i≥1 是 [0.1·2^((i−1)/8), 0.1·2^(i/8))。</summary>
|
||||
public static readonly int BucketCount = 1 + (int)Math.Ceiling(BucketsPerOctave * Math.Log2(MaxValueMs / MinValueMs));
|
||||
|
||||
private int[] _index = new int[4];
|
||||
private ulong[] _counts = new ulong[4];
|
||||
private int _used;
|
||||
|
||||
public override AccKind Kind => AccKind.Histogram;
|
||||
|
||||
public ulong Count { get; private set; }
|
||||
|
||||
public double Sum { get; private set; }
|
||||
|
||||
public double Min { get; private set; } = double.PositiveInfinity;
|
||||
|
||||
public double Max { get; private set; } = double.NegativeInfinity;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Mean => Count == 0 ? null : Sum / Count;
|
||||
|
||||
public int NonEmptyBuckets => _used;
|
||||
|
||||
public static int BucketOf(double valueMs)
|
||||
{
|
||||
if (!(valueMs >= MinValueMs))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int bucket = 1 + (int)Math.Floor(BucketsPerOctave * Math.Log2(valueMs / MinValueMs));
|
||||
return Math.Min(bucket, BucketCount - 1);
|
||||
}
|
||||
|
||||
public static double LowerBound(int bucket) => bucket == 0 ? 0 : MinValueMs * Math.Pow(2, (bucket - 1) / (double)BucketsPerOctave);
|
||||
|
||||
public static double UpperBound(int bucket) => MinValueMs * Math.Pow(2, bucket / (double)BucketsPerOctave);
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
if (double.IsNaN(a) || a < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddToBucket(BucketOf(a), 1);
|
||||
Count++;
|
||||
Sum += a;
|
||||
Min = Math.Min(Min, a);
|
||||
Max = Math.Max(Max, a);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<HistogramAcc>(other);
|
||||
for (int i = 0; i < o._used; i++)
|
||||
{
|
||||
AddToBucket(o._index[i], o._counts[i]);
|
||||
}
|
||||
|
||||
Count += o.Count;
|
||||
Sum += o.Sum;
|
||||
Min = Math.Min(Min, o.Min);
|
||||
Max = Math.Max(Max, o.Max);
|
||||
}
|
||||
|
||||
public override Accumulator Clone()
|
||||
{
|
||||
var copy = new HistogramAcc
|
||||
{
|
||||
_index = (int[])_index.Clone(),
|
||||
_counts = (ulong[])_counts.Clone(),
|
||||
_used = _used,
|
||||
Count = Count,
|
||||
Sum = Sum,
|
||||
Min = Min,
|
||||
Max = Max,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>分位数:取累计计数首次达到 q·N 的桶的几何中点,并限制在实际的最小/最大值之内。</summary>
|
||||
public double? Quantile(double q)
|
||||
{
|
||||
if (Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double target = Math.Clamp(q, 0, 1) * Count;
|
||||
ulong cumulative = 0;
|
||||
for (int i = 0; i < _used; i++)
|
||||
{
|
||||
cumulative += _counts[i];
|
||||
if (cumulative >= target && cumulative > 0)
|
||||
{
|
||||
int bucket = _index[i];
|
||||
double estimate = bucket == 0 ? Min : Math.Sqrt(LowerBound(bucket) * UpperBound(bucket));
|
||||
return Math.Clamp(estimate, Min, Max);
|
||||
}
|
||||
}
|
||||
|
||||
return Max;
|
||||
}
|
||||
|
||||
/// <summary>按桶遍历(桶号升序)。</summary>
|
||||
public IEnumerable<(int Bucket, ulong Count)> Buckets()
|
||||
{
|
||||
for (int i = 0; i < _used; i++)
|
||||
{
|
||||
yield return (_index[i], _counts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用于反序列化:直接设置状态。</summary>
|
||||
internal void Restore(IReadOnlyList<(int Bucket, ulong Count)> buckets, ulong count, double sum, double min, double max)
|
||||
{
|
||||
_used = 0;
|
||||
foreach ((int bucket, ulong n) in buckets)
|
||||
{
|
||||
AddToBucket(bucket, n);
|
||||
}
|
||||
|
||||
Count = count;
|
||||
Sum = sum;
|
||||
Min = min;
|
||||
Max = max;
|
||||
}
|
||||
|
||||
private void AddToBucket(int bucket, ulong n)
|
||||
{
|
||||
int pos = Array.BinarySearch(_index, 0, _used, bucket);
|
||||
if (pos >= 0)
|
||||
{
|
||||
_counts[pos] += n;
|
||||
return;
|
||||
}
|
||||
|
||||
pos = ~pos;
|
||||
if (_used == _index.Length)
|
||||
{
|
||||
Array.Resize(ref _index, _used * 2);
|
||||
Array.Resize(ref _counts, _used * 2);
|
||||
}
|
||||
|
||||
Array.Copy(_index, pos, _index, pos + 1, _used - pos);
|
||||
Array.Copy(_counts, pos, _counts, pos + 1, _used - pos);
|
||||
_index[pos] = bucket;
|
||||
_counts[pos] = n;
|
||||
_used++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 估计两端的时钟偏移和线性漂移:单向时延 x(t) = 偏移 + 漂移·t + 真实的时延变化。
|
||||
/// 最小二乘拟合出直线,残差再减去残差的低分位(时延“地板”),就是去除偏移和漂移之后的抖动。
|
||||
/// </summary>
|
||||
public static class ClockDriftFit
|
||||
{
|
||||
public const int MinPoints = 8;
|
||||
|
||||
/// <summary>拟合 y = a + b·x,x 先居中以保证数值稳定。样本不足或 x 全部相同时返回 false。</summary>
|
||||
public static bool TryFit(ReadOnlySpan<double> x, ReadOnlySpan<double> y, out double intercept, out double slope, out double xMean)
|
||||
{
|
||||
intercept = 0;
|
||||
slope = 0;
|
||||
xMean = 0;
|
||||
int n = x.Length;
|
||||
if (n < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double sumX = 0;
|
||||
double sumY = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
sumX += x[i];
|
||||
sumY += y[i];
|
||||
}
|
||||
|
||||
xMean = sumX / n;
|
||||
double yMean = sumY / n;
|
||||
double sxx = 0;
|
||||
double sxy = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dx = x[i] - xMean;
|
||||
sxx += dx * dx;
|
||||
sxy += dx * (y[i] - yMean);
|
||||
}
|
||||
|
||||
if (sxx <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
slope = sxy / sxx;
|
||||
intercept = yMean - (slope * xMean);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 去除偏移和漂移后的时延变化(非负)。样本少于 <see cref="MinPoints"/> 时返回 null(不产生观测值)。
|
||||
/// </summary>
|
||||
public static double[]? Jitter(ReadOnlySpan<double> timesUs, ReadOnlySpan<double> delaysUs)
|
||||
{
|
||||
int n = timesUs.Length;
|
||||
if (n < MinPoints || !TryFit(timesUs, delaysUs, out double intercept, out double slope, out double xMean))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double[] residual = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
residual[i] = delaysUs[i] - (intercept + (slope * (timesUs[i] - xMean)));
|
||||
}
|
||||
|
||||
double[] sorted = (double[])residual.Clone();
|
||||
Array.Sort(sorted);
|
||||
double floor = sorted[(int)(0.05 * (n - 1))];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
residual[i] = Math.Max(0, residual[i] - floor);
|
||||
}
|
||||
|
||||
return residual;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>变点检测(均值漂移):找到使前后两段均值差最显著的位置,用来判断 QoS 从什么时候开始限速。</summary>
|
||||
public static class ChangePoint
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回变点的下标(后一段的第一个点),没有显著变点时返回 -1。
|
||||
/// 显著性:前后两段的均值差 ≥ <paramref name="minShift"/>,并且每段至少 <paramref name="minSegment"/> 个点。
|
||||
/// NaN(缺失)的点会被忽略。
|
||||
/// </summary>
|
||||
public static int Detect(IReadOnlyList<double> values, double minShift, int minSegment = 3)
|
||||
{
|
||||
int n = values.Count;
|
||||
if (n < 2 * minSegment)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
double[] prefix = new double[n + 1];
|
||||
int[] counts = new int[n + 1];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
bool valid = !double.IsNaN(values[i]);
|
||||
prefix[i + 1] = prefix[i] + (valid ? values[i] : 0);
|
||||
counts[i + 1] = counts[i] + (valid ? 1 : 0);
|
||||
}
|
||||
|
||||
int best = -1;
|
||||
double bestScore = 0;
|
||||
for (int k = minSegment; k <= n - minSegment; k++)
|
||||
{
|
||||
int leftN = counts[k];
|
||||
int rightN = counts[n] - counts[k];
|
||||
if (leftN < minSegment || rightN < minSegment)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double shift = Math.Abs(((prefix[n] - prefix[k]) / rightN) - (prefix[k] / leftN));
|
||||
double score = shift * Math.Sqrt((double)leftN * rightN / (leftN + rightN));
|
||||
if (shift >= minShift && score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第一次显著跳变:第一个比之前所有点的最大值还高出 <paramref name="minJump"/> 的点的下标;没有则返回 -1。
|
||||
/// 用于码率阶梯:“从哪一档开始限速”是第一次出现丢包跳变的那一档,而不是均值差最大的分割点。
|
||||
/// </summary>
|
||||
public static int FirstJump(IReadOnlyList<double> values, double minJump)
|
||||
{
|
||||
double max = double.NegativeInfinity;
|
||||
for (int i = 0; i < values.Count; i++)
|
||||
{
|
||||
if (double.IsNaN(values[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i > 0 && !double.IsNegativeInfinity(max) && values[i] - max >= minJump)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
max = Math.Max(max, values[i]);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>算子把观测值交给这里;窗口评估器负责按时间分桶。</summary>
|
||||
public interface IObservationSink
|
||||
{
|
||||
void Emit(long utcMs, double a, double b = 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 原始块的列视图:按列名读取,并把 Probe / Agent 各自的单调时钟换算成 UTC。
|
||||
/// Agent 时钟 → UTC 使用块头的锚点;Probe 时钟 → Agent 时钟使用估计的偏移。
|
||||
/// </summary>
|
||||
public sealed class ChunkView
|
||||
{
|
||||
public ChunkView(RawChunk chunk)
|
||||
{
|
||||
Chunk = chunk;
|
||||
}
|
||||
|
||||
public RawChunk Chunk { get; }
|
||||
|
||||
public RawChunkHeader Header => Chunk.Header;
|
||||
|
||||
public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.AgentMonoUs, 1000);
|
||||
|
||||
public long ProbeToUtcMs(long probeUs) => AgentToUtcMs(probeUs + Header.Clock.ProbeOffsetUs);
|
||||
|
||||
public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsProbe ? ProbeToUtcMs(value) : AgentToUtcMs(value);
|
||||
|
||||
public double ParamNumber(string name, double fallback) =>
|
||||
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback;
|
||||
|
||||
private static long FloorDiv(long a, long b) => (a / b) - ((a % b != 0 && (a < 0) != (b < 0)) ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
public enum Unit
|
||||
{
|
||||
None,
|
||||
Ms,
|
||||
Pct,
|
||||
Count,
|
||||
PerMin,
|
||||
PerHour,
|
||||
Mbps,
|
||||
Packets,
|
||||
Score,
|
||||
}
|
||||
|
||||
public enum Better
|
||||
{
|
||||
None,
|
||||
Lower,
|
||||
Higher,
|
||||
}
|
||||
|
||||
public enum Stat
|
||||
{
|
||||
P50,
|
||||
P90,
|
||||
P95,
|
||||
P99,
|
||||
Mean,
|
||||
Max,
|
||||
Min,
|
||||
Pct,
|
||||
Count,
|
||||
PerMin,
|
||||
PerHour,
|
||||
P999,
|
||||
|
||||
/// <summary>长尾比:p99 / p50(从同一个直方图导出,所以窗口合并之后仍然精确)。</summary>
|
||||
Tail,
|
||||
}
|
||||
|
||||
/// <summary>算子:把一个流里的原始行变成观测值。算子都是纯函数,每个都有边界用例测试。</summary>
|
||||
public abstract class MetricOp
|
||||
{
|
||||
/// <summary>算子的名称和参数,参与指标指纹;改变语义时必须同时改变它。</summary>
|
||||
public abstract string Signature { get; }
|
||||
|
||||
public abstract void Evaluate(ChunkView view, RawStream stream, IObservationSink sink);
|
||||
}
|
||||
|
||||
/// <summary>导出值:<c>指标id.统计量</c>,例如 <c>rtt.p95</c>。已发布的 id 不能改名,只能新增。</summary>
|
||||
public sealed record MetricExport(string Id, Stat Stat, Unit Unit, double? Good = null, double? Poor = null);
|
||||
|
||||
/// <summary>一行声明 = 一个指标:输入(kind.stream)+ 算子 + 累加器 + 导出。</summary>
|
||||
public sealed class MetricDef
|
||||
{
|
||||
public MetricDef(string id, AccKind kind, IReadOnlyList<string> from, MetricOp op, string label, string description, Better better, IReadOnlyList<MetricExport> exports, int version = 1)
|
||||
{
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
From = from;
|
||||
Op = op;
|
||||
Label = label;
|
||||
Description = description;
|
||||
Better = better;
|
||||
Exports = exports;
|
||||
Version = version;
|
||||
Fingerprint = ComputeFingerprint();
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public AccKind Kind { get; }
|
||||
|
||||
/// <summary><c>kind.stream</c>;<c>*</c> 表示所有具有该 stream 的 Kind。</summary>
|
||||
public IReadOnlyList<string> From { get; }
|
||||
|
||||
public MetricOp Op { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public Better Better { get; }
|
||||
|
||||
public IReadOnlyList<MetricExport> Exports { get; }
|
||||
|
||||
public int Version { get; }
|
||||
|
||||
/// <summary>由声明内容和算子签名计算得出,变化时触发回填。</summary>
|
||||
public string Fingerprint { get; }
|
||||
|
||||
public bool Matches(string kind, string stream)
|
||||
{
|
||||
foreach (string source in From)
|
||||
{
|
||||
int dot = source.IndexOf('.', StringComparison.Ordinal);
|
||||
if (source.AsSpan(dot + 1).SequenceEqual(stream) && (source.AsSpan(0, dot).SequenceEqual("*") || source.AsSpan(0, dot).SequenceEqual(kind)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>从累加器导出一个值;没有样本时返回 null。<paramref name="durationSeconds"/> 用于速率类导出。</summary>
|
||||
public static double? Export(Accumulator acc, MetricExport export, double durationSeconds)
|
||||
{
|
||||
switch (acc)
|
||||
{
|
||||
case HistogramAcc h:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.P50 => h.Quantile(0.5),
|
||||
Stat.P90 => h.Quantile(0.9),
|
||||
Stat.P95 => h.Quantile(0.95),
|
||||
Stat.P99 => h.Quantile(0.99),
|
||||
Stat.P999 => h.Quantile(0.999),
|
||||
Stat.Tail => h.Quantile(0.5) is > 0 and double p50 && h.Quantile(0.99) is { } p99 ? p99 / p50 : null,
|
||||
Stat.Mean => h.Mean,
|
||||
Stat.Max => h.Count == 0 ? null : h.Max,
|
||||
Stat.Min => h.Count == 0 ? null : h.Min,
|
||||
_ => null,
|
||||
};
|
||||
case RatioAcc r:
|
||||
return export.Stat == Stat.Pct ? r.Percent : null;
|
||||
case CounterAcc c:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.Count => c.Value,
|
||||
Stat.PerMin => durationSeconds > 0 ? c.Value / (durationSeconds / 60.0) : null,
|
||||
Stat.PerHour => durationSeconds > 0 ? c.Value / (durationSeconds / 3600.0) : null,
|
||||
_ => null,
|
||||
};
|
||||
case MaxAcc m:
|
||||
return m.Result;
|
||||
case MinAcc n:
|
||||
return n.Result;
|
||||
case SumAcc s:
|
||||
return export.Stat == Stat.Mean ? s.Mean : s.Count == 0 ? null : s.Sum;
|
||||
case RunLengthAcc rl:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.Max => rl.Runs == 0 ? null : rl.Max,
|
||||
Stat.Mean => rl.Mean,
|
||||
_ => null,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeFingerprint()
|
||||
{
|
||||
var text = new StringBuilder()
|
||||
.Append('v').Append(Version).Append('|').Append(Id).Append('|').Append(Kind).Append('|')
|
||||
.AppendJoin(',', From).Append('|').Append(Op.Signature).Append('|');
|
||||
foreach (MetricExport e in Exports)
|
||||
{
|
||||
text.Append(e.Id).Append(':').Append(e.Stat).Append(';');
|
||||
}
|
||||
|
||||
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString())))[..16];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>声明指标的简写。</summary>
|
||||
public static class Metric
|
||||
{
|
||||
public static MetricDef Histogram(string id, string[] from, MetricOp op, string label, string description, Unit unit, Stat[] exports, Better better = Better.Lower, double? good = null, double? poor = null) =>
|
||||
new(id, AccKind.Histogram, from, op, label, description, better, [.. exports.Select(s => s == Stat.Tail ? new MetricExport(id + "." + StatName(s), s, Unit.None) : new MetricExport(id + "." + StatName(s), s, unit, good, poor))]);
|
||||
|
||||
public static MetricDef Ratio(string id, string[] from, MetricOp op, string label, string description, Better better = Better.Lower, double? good = null, double? poor = null, string? exportId = null) =>
|
||||
new(id, AccKind.Ratio, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Pct, Unit.Pct, good, poor)]);
|
||||
|
||||
public static MetricDef Counter(string id, string[] from, MetricOp op, string label, string description, MetricExport export, Better better = Better.Lower) =>
|
||||
new(id, AccKind.Counter, from, op, label, description, better, [export]);
|
||||
|
||||
public static MetricDef Max(string id, string[] from, MetricOp op, string label, string description, Unit unit, string? exportId = null, Better better = Better.Lower, double? good = null, double? poor = null) =>
|
||||
new(id, AccKind.Max, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Max, unit, good, poor)]);
|
||||
|
||||
public static MetricDef RunLength(string id, string[] from, MetricOp op, string label, string description, params MetricExport[] exports) =>
|
||||
new(id, AccKind.RunLength, from, op, label, description, Better.Lower, exports);
|
||||
|
||||
public static string StatName(Stat stat) => stat switch
|
||||
{
|
||||
Stat.P50 => "p50",
|
||||
Stat.P90 => "p90",
|
||||
Stat.P95 => "p95",
|
||||
Stat.P99 => "p99",
|
||||
Stat.Mean => "mean",
|
||||
Stat.Max => "max",
|
||||
Stat.Min => "min",
|
||||
Stat.Pct => "pct",
|
||||
Stat.Count => "count",
|
||||
Stat.PerMin => "per_min",
|
||||
Stat.P999 => "p999",
|
||||
Stat.Tail => "tail",
|
||||
_ => "per_h",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Text.Json;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
public sealed class TestStepResult
|
||||
{
|
||||
public int Index { get; set; }
|
||||
|
||||
public double TargetMbps { get; set; }
|
||||
|
||||
public double LossPct { get; set; }
|
||||
|
||||
public double? JitterP95Ms { get; set; }
|
||||
|
||||
public double DeliveredMbps { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TestResult
|
||||
{
|
||||
/// <summary>导出值:与持续指标同一套 id,另有 <c>hy2.*</c> 等测试专用的值。</summary>
|
||||
public Dictionary<string, double> Metrics { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>逐步或逐秒的曲线,例如码率–丢包曲线。</summary>
|
||||
public Dictionary<string, double[]> Series { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public List<TestStepResult> Steps { get; } = [];
|
||||
|
||||
public List<string> Caveats { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
|
||||
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
|
||||
/// </summary>
|
||||
public static class TestEvaluator
|
||||
{
|
||||
public const double SustainableLossPct = 5;
|
||||
public const double SustainableLossLoosePct = 10;
|
||||
public const double QosShiftPct = 5;
|
||||
|
||||
public static TestResult Evaluate(string plan, IReadOnlyList<RawChunk> chunks)
|
||||
{
|
||||
var result = new TestResult();
|
||||
EvaluateMetrics(chunks, result);
|
||||
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
|
||||
{
|
||||
EvaluateLadder(chunks, result);
|
||||
}
|
||||
|
||||
if (chunks.Any(c => c.Header.Partial))
|
||||
{
|
||||
result.Caveats.Add("partial_data");
|
||||
}
|
||||
|
||||
if (chunks.Any(c => c.Header.Port is not (0 or 443)))
|
||||
{
|
||||
result.Caveats.Add("port_differs_from_service");
|
||||
}
|
||||
|
||||
_ = plan;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EvaluateMetrics(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||||
{
|
||||
var buckets = new SortedDictionary<long, MetricSet>();
|
||||
long span = 24L * 3600 * 1000;
|
||||
foreach (RawChunk chunk in chunks)
|
||||
{
|
||||
WindowEvaluator.Default.Evaluate(chunk, span, buckets);
|
||||
}
|
||||
|
||||
var merged = new MetricSet();
|
||||
foreach (MetricSet set in buckets.Values)
|
||||
{
|
||||
merged.Merge(set);
|
||||
}
|
||||
|
||||
double seconds = chunks.Sum(c => c.Header.DurationMs) / 1000.0;
|
||||
foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports())
|
||||
{
|
||||
if (merged.TryGet(def.Id, out Accumulator acc) && MetricDef.Export(acc, export, seconds) is { } value)
|
||||
{
|
||||
result.Metrics[export.Id] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvaluateLadder(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||||
{
|
||||
var steps = new List<TestStepResult>();
|
||||
var perSecond = new SortedDictionary<long, double>();
|
||||
int index = 0;
|
||||
|
||||
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 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))
|
||||
{
|
||||
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var merged = new MetricSet();
|
||||
long bytes = 0;
|
||||
foreach (RawChunk chunk in parts)
|
||||
{
|
||||
var buckets = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet<string> { "train.loss", "train.jitter", "train.rate" });
|
||||
foreach (MetricSet set in buckets.Values)
|
||||
{
|
||||
merged.Merge(set);
|
||||
}
|
||||
|
||||
bytes += ReceivedBytes(chunk.Find("pkt")!);
|
||||
|
||||
var seconds = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet<string> { "train.rate" });
|
||||
foreach ((long start, MetricSet set) in seconds)
|
||||
{
|
||||
if (set.TryGet("train.rate", out Accumulator rate) && rate is HistogramAcc rh && rh.Mean is { } mean)
|
||||
{
|
||||
perSecond[start] = mean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var step = new TestStepResult
|
||||
{
|
||||
Index = index++,
|
||||
TargetMbps = TargetMbps(parts[0].Header),
|
||||
};
|
||||
if (merged.TryGet("train.loss", out Accumulator loss) && loss is RatioAcc ratio)
|
||||
{
|
||||
step.LossPct = ratio.Percent ?? 0;
|
||||
}
|
||||
|
||||
if (merged.TryGet("train.jitter", out Accumulator jitter) && jitter is HistogramAcc jh)
|
||||
{
|
||||
step.JitterP95Ms = jh.Quantile(0.95);
|
||||
}
|
||||
|
||||
// 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
|
||||
double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0);
|
||||
step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0;
|
||||
steps.Add(step);
|
||||
}
|
||||
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result.Steps.AddRange(steps);
|
||||
result.Series["step_mbps"] = [.. steps.Select(s => s.TargetMbps)];
|
||||
result.Series["loss_pct"] = [.. steps.Select(s => s.LossPct)];
|
||||
result.Series["delivered_mbps"] = [.. steps.Select(s => s.DeliveredMbps)];
|
||||
result.Series["jitter_p95_ms"] = [.. steps.Select(s => s.JitterP95Ms ?? double.NaN)];
|
||||
|
||||
TestStepResult? sustainable = steps.Where(s => s.LossPct <= SustainableLossPct).MaxBy(s => s.TargetMbps);
|
||||
TestStepResult? loose = steps.Where(s => s.LossPct <= SustainableLossLoosePct).MaxBy(s => s.TargetMbps);
|
||||
result.Metrics["hy2.sustainable_mbps"] = sustainable?.TargetMbps ?? 0;
|
||||
result.Metrics["hy2.sustainable10_mbps"] = loose?.TargetMbps ?? 0;
|
||||
result.Metrics["hy2.effective_mbps"] = steps.Max(s => s.DeliveredMbps);
|
||||
|
||||
// 限速开始:丢包率随码率阶梯出现显著跳变的那一档。
|
||||
int onset = ChangePoint.FirstJump([.. steps.Select(s => s.LossPct)], QosShiftPct);
|
||||
if (onset >= 0)
|
||||
{
|
||||
result.Metrics["hy2.qos_onset_mbps"] = steps[onset].TargetMbps;
|
||||
}
|
||||
|
||||
if (perSecond.Count >= 6)
|
||||
{
|
||||
double[] rates = [.. perSecond.Values];
|
||||
double peak = rates.Max();
|
||||
int second = ChangePoint.Detect(rates, Math.Max(1, peak * 0.2));
|
||||
if (second >= 0 && rates[second..].Average() < rates[..second].Average())
|
||||
{
|
||||
result.Metrics["hy2.qos_onset_s"] = second;
|
||||
}
|
||||
|
||||
result.Series["delivered_mbps_per_s"] = rates;
|
||||
}
|
||||
}
|
||||
|
||||
private static double TargetMbps(RawChunkHeader header) =>
|
||||
header.Params.TryGetValue("mbps", out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0;
|
||||
|
||||
/// <summary>
|
||||
/// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。
|
||||
/// 没有时间戳就返回 null。
|
||||
/// </summary>
|
||||
private static double? SendSpanSeconds(RawChunk[] parts)
|
||||
{
|
||||
long min = long.MaxValue;
|
||||
long max = long.MinValue;
|
||||
long count = 0;
|
||||
foreach (RawChunk chunk in parts)
|
||||
{
|
||||
RawStream? pkt = chunk.Find("pkt");
|
||||
RawColumn? send = pkt?.Find("t_send");
|
||||
if (pkt is null || send is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ChunkClock clock = chunk.Header.Clock;
|
||||
for (int i = 0; i < pkt.Count; i++)
|
||||
{
|
||||
if (!send.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs);
|
||||
min = Math.Min(min, utcUs);
|
||||
max = Math.Max(max, utcUs);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 2 || max <= min)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (max - min) / 1_000_000.0 * count / (count - 1);
|
||||
}
|
||||
|
||||
private static long ReceivedBytes(RawStream pkt)
|
||||
{
|
||||
RawColumn? recv = pkt.Find("t_recv");
|
||||
RawColumn? size = pkt.Find("size");
|
||||
if (recv is null || size is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long bytes = 0;
|
||||
for (int i = 0; i < pkt.Count; i++)
|
||||
{
|
||||
if (recv.IsValid(i))
|
||||
{
|
||||
bytes += size.Values[i];
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 把原始块按时间分桶算成累加器。流式路径(1s 桶)和持久路径(分钟窗口)调用同一份代码,
|
||||
/// 所以实时值与历史值使用的是同一套口径。
|
||||
/// </summary>
|
||||
public sealed class WindowEvaluator
|
||||
{
|
||||
private readonly IReadOnlyList<MetricDef> _metrics;
|
||||
|
||||
public WindowEvaluator(IReadOnlyList<MetricDef> metrics)
|
||||
{
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public static WindowEvaluator Default { get; } = new(MetricRegistry.All);
|
||||
|
||||
/// <summary>
|
||||
/// 评估一个块,结果合并进 <paramref name="buckets"/>(键是桶起点的 UTC 毫秒)。
|
||||
/// <paramref name="only"/> 不为空时只计算这些指标(用于回填新增或变更的指标)。
|
||||
/// </summary>
|
||||
public void Evaluate(RawChunk chunk, long bucketMs, IDictionary<long, MetricSet> buckets, ISet<string>? only = null)
|
||||
{
|
||||
var view = new ChunkView(chunk);
|
||||
foreach (RawStream stream in chunk.Streams)
|
||||
{
|
||||
foreach (MetricDef metric in _metrics)
|
||||
{
|
||||
if ((only is not null && !only.Contains(metric.Id)) || !metric.Matches(chunk.Header.Kind, stream.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
metric.Op.Evaluate(view, stream, new BucketSink(metric, bucketMs, buckets));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BucketSink : IObservationSink
|
||||
{
|
||||
private readonly MetricDef _metric;
|
||||
private readonly long _bucketMs;
|
||||
private readonly IDictionary<long, MetricSet> _buckets;
|
||||
|
||||
public BucketSink(MetricDef metric, long bucketMs, IDictionary<long, MetricSet> buckets)
|
||||
{
|
||||
_metric = metric;
|
||||
_bucketMs = bucketMs;
|
||||
_buckets = buckets;
|
||||
}
|
||||
|
||||
public void Emit(long utcMs, double a, double b = 0)
|
||||
{
|
||||
long start = Math.DivRem(utcMs, _bucketMs, out long rem) * _bucketMs;
|
||||
if (rem < 0)
|
||||
{
|
||||
start -= _bucketMs;
|
||||
}
|
||||
|
||||
if (!_buckets.TryGetValue(start, out MetricSet? set))
|
||||
{
|
||||
set = new MetricSet();
|
||||
_buckets[start] = set;
|
||||
}
|
||||
|
||||
set.GetOrAdd(_metric.Id, _metric.Kind).Add(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>线路基础指标(udp_echo / icmp_echo 的回显流)。新增指标 = 加一行声明。</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"];
|
||||
|
||||
public static readonly MetricDef Rtt = Metric.Histogram(
|
||||
"rtt", s_udpOrIcmpMsg, Ops.Rtt, "RTT", "Round-trip time with the Agent processing time removed.", Unit.Ms,
|
||||
[Stat.P50, Stat.P95, Stat.P99, Stat.Mean], good: 50, poor: 150);
|
||||
|
||||
public static readonly MetricDef LossUp = Metric.Ratio(
|
||||
"loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the Probe and the Agent.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef LossDown = Metric.Ratio(
|
||||
"loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the Probe, among packets that reached the Agent.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef LossTotal = Metric.Ratio(
|
||||
"loss.total", s_udpOrIcmpMsg, Ops.Loss(Dir.Total), "Loss (total)", "Packets whose reply never came back.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef JitterUp = Metric.Histogram(
|
||||
"jitter.up", s_udpMsg, Ops.OwdVar(Dir.Up), "Jitter (up)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
|
||||
public static readonly MetricDef JitterDown = Metric.Histogram(
|
||||
"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.",
|
||||
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
|
||||
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
|
||||
|
||||
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));
|
||||
|
||||
public static readonly MetricDef Dup = Metric.Counter(
|
||||
"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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
public sealed record PresetDef(string Id, string Label, IReadOnlyList<string> MetricIds);
|
||||
|
||||
/// <summary>
|
||||
/// 指标注册表:存储、降采样、API、目录都由它驱动。新增指标时,在对应的 Metrics 文件里加一行声明,
|
||||
/// 再把它放进这里的 <see cref="All"/>(显式注册,AOT 安全,不使用反射)。
|
||||
/// </summary>
|
||||
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,
|
||||
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,
|
||||
SessionMetrics.HoldResets, SessionMetrics.HoldLifetime,
|
||||
SessionMetrics.TrainLoss, SessionMetrics.TrainJitter, SessionMetrics.TrainRate, SessionMetrics.SamplesConnect,
|
||||
];
|
||||
|
||||
/// <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("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"]),
|
||||
new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, MetricDef> s_byId = All.ToDictionary(m => m.Id, StringComparer.Ordinal);
|
||||
|
||||
private static readonly Dictionary<string, (MetricDef Def, MetricExport Export)> s_byExport = BuildExportIndex();
|
||||
|
||||
public static MetricDef? Find(string metricId) => s_byId.GetValueOrDefault(metricId);
|
||||
|
||||
/// <summary>按导出 id(例如 <c>rtt.p95</c>)查找。</summary>
|
||||
public static bool TryFindExport(string exportId, out MetricDef def, out MetricExport export)
|
||||
{
|
||||
if (s_byExport.TryGetValue(exportId, out (MetricDef Def, MetricExport Export) found))
|
||||
{
|
||||
def = found.Def;
|
||||
export = found.Export;
|
||||
return true;
|
||||
}
|
||||
|
||||
def = null!;
|
||||
export = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static PresetDef? FindPreset(string id) => Presets.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
public static IEnumerable<(MetricDef Def, MetricExport Export)> AllExports() =>
|
||||
All.SelectMany(def => def.Exports.Select(export => (def, export)));
|
||||
|
||||
private static Dictionary<string, (MetricDef, MetricExport)> BuildExportIndex()
|
||||
{
|
||||
var index = new Dictionary<string, (MetricDef, MetricExport)>(StringComparer.Ordinal);
|
||||
foreach (MetricDef def in All)
|
||||
{
|
||||
foreach (MetricExport export in def.Exports)
|
||||
{
|
||||
if (!index.TryAdd(export.Id, (def, export)))
|
||||
{
|
||||
throw new InvalidOperationException($"duplicate export id {export.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</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"];
|
||||
|
||||
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,
|
||||
[new MetricExport("connect.success", Stat.Pct, Unit.Pct, 99, 90)]);
|
||||
|
||||
public static readonly MetricDef ConnectTcp = Metric.Histogram(
|
||||
"connect.tcp_ms", s_conn, Ops.PhaseMs("tcp_us"), "TCP connect time", "Time to establish the TCP connection.", Unit.Ms, [Stat.P95], good: 100, poor: 400);
|
||||
|
||||
public static readonly MetricDef ConnectTls = Metric.Histogram(
|
||||
"connect.tls_ms", s_conn, Ops.PhaseMs("tls_us"), "TLS handshake time", "Time for the TLS handshake after TCP is up.", Unit.Ms, [Stat.P95], good: 200, poor: 800);
|
||||
|
||||
public static readonly MetricDef FailTimeout = Metric.Counter(
|
||||
"connect.fail.timeout", s_conn, Ops.OutcomeCount(ConnectOutcome.Timeout), "Connect timeouts", "Attempts that timed out.", new MetricExport("connect.fail.timeout", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef FailReset = Metric.Counter(
|
||||
"connect.fail.rst", s_conn, Ops.OutcomeCount(ConnectOutcome.Reset), "Connect resets", "Attempts answered with a reset.", new MetricExport("connect.fail.rst", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef FailTls = Metric.Counter(
|
||||
"connect.fail.tls", s_conn, Ops.OutcomeCount(ConnectOutcome.TlsAlert), "TLS failures", "Attempts that ended in a TLS alert.", new MetricExport("connect.fail.tls", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef HoldResets = Metric.Counter(
|
||||
"hold.resets", s_hold, Ops.HoldEvents(HoldEvent.Reset), "Held connection resets", "Times the held connection was reset.", new MetricExport("hold.resets.per_h", Stat.PerHour, Unit.PerHour, 0.5, 4));
|
||||
|
||||
public static readonly MetricDef HoldLifetime = Metric.Max(
|
||||
"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);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Monitor.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"];
|
||||
|
||||
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,
|
||||
[Stat.P50, Stat.P95, Stat.P99, Stat.P999, Stat.Max, Stat.Tail], good: 60, poor: 200);
|
||||
|
||||
public static readonly MetricDef Slow = Metric.Ratio(
|
||||
"tcp.slow", s_tcpMsg, Ops.SlowShare(minSamples: 20), "TCP long-tail echoes",
|
||||
"Share of echoes slower than the long-tail threshold: max(1.8 x baseline, baseline + max(15 ms, 0.35 x baseline)), where the baseline is the median RTT of the same 10 s chunk.",
|
||||
good: 1, poor: 5);
|
||||
|
||||
public static readonly MetricDef Stall = Metric.Counter(
|
||||
"tcp.stall", s_tcpMsg, Ops.Gap(GapOutput.Count, "rate_hz", thresholdMs: 100), "TCP stalls",
|
||||
"Arrival gaps longer than the expected interval plus 100 ms (head-of-line blocking).",
|
||||
new MetricExport("tcp.stall.per_min", Stat.PerMin, Unit.PerMin, 0.5, 5));
|
||||
|
||||
public static readonly MetricDef StallMax = Metric.Max(
|
||||
"tcp.stall_max", s_tcpMsg, Ops.Gap(GapOutput.MaxMs, "rate_hz", thresholdMs: 100), "Longest TCP stall", "Longest stall in the period.", Unit.Ms, good: 200, poor: 1000);
|
||||
|
||||
public static readonly MetricDef Retrans = Metric.Ratio(
|
||||
"tcp.retrans", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out"), "TCP retransmissions", "Retransmitted segments as a share of segments sent (kernel counters).", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef Srtt = Metric.Histogram(
|
||||
"tcp.srtt", s_tcpInfo, Ops.Column("srtt_us", 0.001), "Kernel srtt", "Smoothed RTT sampled from the kernel.", Unit.Ms, [Stat.P95], good: 60, poor: 200);
|
||||
|
||||
public static readonly MetricDef BulkRate = Metric.Histogram(
|
||||
"tcp.bulk.mbps", s_bulkBytes, Ops.BulkRate, "Bulk throughput", "Aggregate throughput of the bulk transfer streams.", Unit.Mbps,
|
||||
[Stat.P50, Stat.Mean, Stat.Max], Better.Higher);
|
||||
|
||||
public static readonly MetricDef BulkPeak = Metric.Histogram(
|
||||
"tcp.bulk.peak", s_bulkBytes, Ops.BulkPeak, "Peak bulk throughput", "Best sustained 1 s throughput of the bulk transfer: how fast this line can go.", Unit.Mbps,
|
||||
[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);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>线路基础质量:RTT、丢包、抖动、连续丢包。</summary>
|
||||
public sealed class BaseModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_rtt = new((30, 100), (80, 80), (150, 50), (300, 10), (500, 0));
|
||||
private static readonly Curve s_loss = new((0, 100), (0.5, 90), (1, 75), (3, 40), (10, 0));
|
||||
private static readonly Curve s_jitter = new((2, 100), (8, 85), (20, 50), (80, 0));
|
||||
private static readonly Curve s_run = new((1, 100), (3, 80), (10, 30), (30, 0));
|
||||
|
||||
public override string Id => "base";
|
||||
|
||||
public override string Title => "Line quality";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
|
||||
|
||||
public override ModelScore Score(MetricView view) =>
|
||||
Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "rtt.p95", "RTT p95", 3, s_rtt),
|
||||
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),
|
||||
],
|
||||
estimated: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
|
||||
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
|
||||
/// </summary>
|
||||
public sealed class McModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_rtt = new((40, 100), (100, 80), (200, 45), (400, 5), (600, 0));
|
||||
private static readonly Curve s_stallRate = new((0, 100), (1, 90), (5, 60), (20, 20), (60, 0));
|
||||
private static readonly Curve s_stallMax = new((150, 100), (500, 60), (2000, 10), (5000, 0));
|
||||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||||
|
||||
public override string Id => "mc";
|
||||
|
||||
public override string Title => "Minecraft";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["tcp.rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
bool hasTcp = view.Has("tcp.rtt.p95") || view.Has("tcp.stall.per_min");
|
||||
if (hasTcp)
|
||||
{
|
||||
return Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 3, s_rtt),
|
||||
Dim(view, "tcp.stall.per_min", "Stalls per minute", 3, s_stallRate),
|
||||
Dim(view, "tcp.stall_max", "Longest stall", 2, s_stallMax),
|
||||
Dim(view, "tcp.retrans", "Retransmissions", 2, s_retrans),
|
||||
],
|
||||
estimated: false);
|
||||
}
|
||||
|
||||
if (view.Get("loss.total") is not { } lossPct || view.Get("rtt.p95") is not { } rttMs)
|
||||
{
|
||||
return Combine(view, [], estimated: true);
|
||||
}
|
||||
|
||||
// 一个丢失的包至少造成一次约 max(200 ms, 2·RTT) 的卡顿;约一半能被快速重传恢复。
|
||||
const double messagesPerMinute = 1200;
|
||||
double stallsPerMin = lossPct / 100 * messagesPerMinute * 0.5;
|
||||
double stallMs = lossPct > 0 ? Math.Max(200, 2 * rttMs) * (1 + (view.Get("loss.run_max") ?? 1) / 3) : 0;
|
||||
var estimate = new MetricView(new Dictionary<string, double>
|
||||
{
|
||||
["tcp.rtt.p95"] = rttMs + (lossPct > 0 ? Math.Max(200, 2 * rttMs) * lossPct / 100 * 5 : 0),
|
||||
["tcp.stall.per_min"] = stallsPerMin,
|
||||
["tcp.stall_max"] = stallMs,
|
||||
["tcp.retrans"] = lossPct,
|
||||
})
|
||||
{
|
||||
MeasuredOnServicePort = view.MeasuredOnServicePort,
|
||||
};
|
||||
return Combine(
|
||||
estimate,
|
||||
[
|
||||
Dim(estimate, "tcp.rtt.p95", "TCP RTT p95 (estimated)", 3, s_rtt),
|
||||
Dim(estimate, "tcp.stall.per_min", "Stalls per minute (estimated)", 3, s_stallRate),
|
||||
Dim(estimate, "tcp.stall_max", "Longest stall (estimated)", 2, s_stallMax),
|
||||
Dim(estimate, "tcp.retrans", "Retransmissions (estimated)", 2, s_retrans),
|
||||
],
|
||||
estimated: true,
|
||||
"estimated_from_udp");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
|
||||
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
|
||||
/// </summary>
|
||||
public sealed class Hy2Model : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_sustainable = new((0, 0), (10, 30), (25, 60), (50, 80), (100, 100));
|
||||
private static readonly Curve s_qosOnset = new((10, 20), (25, 50), (50, 75), (100, 95));
|
||||
private static readonly Curve s_loss = new((0, 100), (1, 85), (3, 60), (8, 20), (15, 0));
|
||||
private static readonly Curve s_jitter = new((3, 100), (10, 80), (30, 40), (100, 0));
|
||||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||||
|
||||
public override string Id => "hy2";
|
||||
|
||||
public override string Title => "Hysteria2";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["hy2.sustainable_mbps", "hy2.effective_mbps", "hy2.qos_onset_mbps", "loss.total", "jitter.down.p95", "rtt.p95"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
var dimensions = new List<ModelDimension>();
|
||||
bool hasTest = view.Has("hy2.sustainable_mbps");
|
||||
if (hasTest)
|
||||
{
|
||||
dimensions.Add(Dim(view, "hy2.sustainable_mbps", "Sustainable rate (loss ≤ 5%)", 4, s_sustainable));
|
||||
dimensions.Add(Dim(view, "hy2.effective_mbps", "Effective throughput", 2, s_sustainable));
|
||||
dimensions.Add(view.Get("hy2.qos_onset_mbps") is { } onset
|
||||
? new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, onset, s_qosOnset.Evaluate(onset))
|
||||
: new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, null, 100));
|
||||
}
|
||||
|
||||
dimensions.Add(Dim(view, "loss.total", "Packet loss (trend)", hasTest ? 1.5 : 4, s_loss));
|
||||
dimensions.Add(Dim(view, "jitter.down.p95", "Jitter (down)", hasTest ? 0.5 : 1.5, s_jitter));
|
||||
dimensions.Add(Dim(view, "rtt.p95", "RTT p95", hasTest ? 0.5 : 1.5, s_rtt));
|
||||
return Combine(view, dimensions, estimated: !hasTest, hasTest ? [] : ["no_load_test"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>REALITY(TLS 伪装的 TCP 代理):握手成功率与耗时、长连接稳定性、批量吞吐与重传。</summary>
|
||||
public sealed class RealityModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_success = new((80, 10), (95, 60), (99, 90), (100, 100));
|
||||
private static readonly Curve s_tls = new((150, 100), (400, 80), (1000, 40), (3000, 0));
|
||||
private static readonly Curve s_resets = new((0, 100), (1, 80), (4, 40), (12, 0));
|
||||
private static readonly Curve s_bulk = new((10, 30), (100, 60), (400, 85), (800, 100));
|
||||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||||
|
||||
public override string Id => "reality";
|
||||
|
||||
public override string Title => "REALITY";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } =
|
||||
["connect.success", "connect.tls_ms.p95", "hold.resets.per_h", "tcp.bulk.peak.max", "tcp.retrans", "tcp.rtt.p95"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
bool hasSession = view.Has("connect.success");
|
||||
return Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "connect.success", "Connect success", 3, s_success),
|
||||
Dim(view, "connect.tls_ms.p95", "TLS handshake p95", 2, s_tls),
|
||||
Dim(view, "hold.resets.per_h", "Held connection resets", 2, s_resets),
|
||||
Dim(view, "tcp.bulk.peak.max", "Peak bulk throughput", 2, s_bulk),
|
||||
Dim(view, "tcp.retrans", "Retransmissions", 1, s_retrans),
|
||||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 1, s_rtt),
|
||||
],
|
||||
estimated: !hasSession,
|
||||
hasSession ? [] : ["no_connect_data"]);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<IServiceModel> All = [new BaseModel(), new McModel(), new Hy2Model(), new RealityModel()];
|
||||
|
||||
public static IServiceModel? Find(string id) => All.FirstOrDefault(m => m.Id == id);
|
||||
|
||||
/// <summary>计算全部模型的评分(score.<模型id>)。</summary>
|
||||
public static Dictionary<string, ModelScore> ScoreAll(MetricView view)
|
||||
{
|
||||
var scores = new Dictionary<string, ModelScore>(StringComparer.Ordinal);
|
||||
foreach (IServiceModel model in All)
|
||||
{
|
||||
scores[model.Id] = model.Score(view);
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>模型的输入:一个时间段内的导出值(例如 <c>rtt.p95</c>)。没有数据的指标不存在。</summary>
|
||||
public sealed class MetricView
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, double> _values;
|
||||
|
||||
public MetricView(IReadOnlyDictionary<string, double> values)
|
||||
{
|
||||
_values = values;
|
||||
}
|
||||
|
||||
/// <summary>测量端口与被推算服务的常用端口(443)一致时为 true。默认端口 7443/7444 下总是 false。</summary>
|
||||
public bool MeasuredOnServicePort { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, double> Values => _values;
|
||||
|
||||
public bool Has(string id) => _values.ContainsKey(id);
|
||||
|
||||
public double? Get(string id) => _values.TryGetValue(id, out double v) ? v : null;
|
||||
}
|
||||
|
||||
public sealed record ModelDimension(string Id, string Label, double Weight, double? Value, double? Score);
|
||||
|
||||
/// <summary>评分 0–100;没有任何可用维度时为 null。</summary>
|
||||
public sealed record ModelScore(string ModelId, int Version, double? Score, IReadOnlyList<ModelDimension> Dimensions, IReadOnlyList<string> Caveats, bool Estimated);
|
||||
|
||||
/// <summary>
|
||||
/// 服务体验模型:只读取指标,不读取原始列;在查询时计算,改动评分公式后立即对全部历史生效。
|
||||
/// 评分函数需要有单调性测试(指标变差时,分数不能升高)。
|
||||
/// </summary>
|
||||
public interface IServiceModel
|
||||
{
|
||||
string Id { get; }
|
||||
|
||||
string Title { get; }
|
||||
|
||||
int Version { get; }
|
||||
|
||||
/// <summary>需要的导出值 id(用于目录展示和判断数据是否足够)。</summary>
|
||||
IReadOnlyList<string> Needs { get; }
|
||||
|
||||
ModelScore Score(MetricView view);
|
||||
}
|
||||
|
||||
/// <summary>分段线性评分曲线:点按 x 升序排列,超出两端时取端点的值。</summary>
|
||||
public sealed class Curve
|
||||
{
|
||||
private readonly (double X, double Y)[] _points;
|
||||
|
||||
public Curve(params (double X, double Y)[] points)
|
||||
{
|
||||
_points = points;
|
||||
}
|
||||
|
||||
public double Evaluate(double x)
|
||||
{
|
||||
if (x <= _points[0].X)
|
||||
{
|
||||
return _points[0].Y;
|
||||
}
|
||||
|
||||
for (int i = 1; i < _points.Length; i++)
|
||||
{
|
||||
if (x <= _points[i].X)
|
||||
{
|
||||
(double x0, double y0) = _points[i - 1];
|
||||
(double x1, double y1) = _points[i];
|
||||
return y0 + ((y1 - y0) * (x - x0) / (x1 - x0));
|
||||
}
|
||||
}
|
||||
|
||||
return _points[^1].Y;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class ServiceModelBase : IServiceModel
|
||||
{
|
||||
protected static readonly string[] BaseCaveats = ["traffic_shape_only"];
|
||||
|
||||
public abstract string Id { get; }
|
||||
|
||||
public abstract string Title { get; }
|
||||
|
||||
public abstract int Version { get; }
|
||||
|
||||
public abstract IReadOnlyList<string> Needs { get; }
|
||||
|
||||
public abstract ModelScore Score(MetricView view);
|
||||
|
||||
protected static ModelDimension Dim(MetricView view, string id, string label, double weight, Curve curve) =>
|
||||
view.Get(id) is { } value ? new ModelDimension(id, label, weight, value, curve.Evaluate(value)) : new ModelDimension(id, label, weight, null, null);
|
||||
|
||||
/// <summary>按权重对可用的维度加权平均(缺失的维度不参与)。</summary>
|
||||
protected ModelScore Combine(MetricView view, List<ModelDimension> dimensions, bool estimated, params string[] extraCaveats)
|
||||
{
|
||||
double weight = 0;
|
||||
double sum = 0;
|
||||
foreach (ModelDimension d in dimensions)
|
||||
{
|
||||
if (d.Score is { } score)
|
||||
{
|
||||
weight += d.Weight;
|
||||
sum += d.Weight * score;
|
||||
}
|
||||
}
|
||||
|
||||
var caveats = new List<string>(BaseCaveats);
|
||||
if (!view.MeasuredOnServicePort)
|
||||
{
|
||||
caveats.Add("port_differs_from_service");
|
||||
}
|
||||
|
||||
caveats.AddRange(extraCaveats);
|
||||
if (estimated)
|
||||
{
|
||||
caveats.Add("estimated");
|
||||
}
|
||||
|
||||
return new ModelScore(Id, Version, weight > 0 ? Math.Round(sum / weight, 1) : null, dimensions, caveats, estimated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\shared\Monitor.Protocol\Monitor.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,446 @@
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
public enum Dir
|
||||
{
|
||||
Up,
|
||||
Down,
|
||||
Total,
|
||||
}
|
||||
|
||||
public enum GapOutput
|
||||
{
|
||||
Count,
|
||||
MaxMs,
|
||||
}
|
||||
|
||||
/// <summary>回显流(stream <c>msg</c>)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。</summary>
|
||||
internal readonly struct EchoColumns
|
||||
{
|
||||
public EchoColumns(RawStream stream)
|
||||
{
|
||||
Count = stream.Count;
|
||||
Seq = stream.Find("seq");
|
||||
T1 = stream.Find("t1");
|
||||
T2 = stream.Find("t2");
|
||||
T3 = stream.Find("t3");
|
||||
T4 = stream.Find("t4");
|
||||
}
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public RawColumn? Seq { get; }
|
||||
|
||||
public RawColumn? T1 { get; }
|
||||
|
||||
public RawColumn? T2 { get; }
|
||||
|
||||
public RawColumn? T3 { get; }
|
||||
|
||||
public RawColumn? T4 { get; }
|
||||
|
||||
public bool HasBasics => T1 is not null && T4 is not null;
|
||||
|
||||
/// <summary>行 i 的发送时刻(UTC 毫秒)。</summary>
|
||||
public long SentUtcMs(ChunkView view, int i) => view.ColumnToUtcMs(T1!, T1!.Values[i]);
|
||||
|
||||
public bool IsUpLost(int i) => T2 is not null && !T2.IsValid(i);
|
||||
|
||||
public bool IsReplyLost(int i) => T4 is null || !T4.IsValid(i);
|
||||
|
||||
/// <summary>按到达时间(t4)排序后的行号,只含收到回复的行。</summary>
|
||||
public int[] ArrivalOrder()
|
||||
{
|
||||
var rows = new List<int>(Count);
|
||||
for (int i = 0; i < Count; i++)
|
||||
{
|
||||
if (!IsReplyLost(i))
|
||||
{
|
||||
rows.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int[] order = [.. rows];
|
||||
long[] keys = new long[order.Length];
|
||||
for (int i = 0; i < order.Length; i++)
|
||||
{
|
||||
keys[i] = T4!.Values[order[i]];
|
||||
}
|
||||
|
||||
Array.Sort(keys, order);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RttOp : MetricOp
|
||||
{
|
||||
public override string Signature => "rtt/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||||
{
|
||||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||||
}
|
||||
|
||||
if (rttUs >= 0)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 长尾回显:每个有回复的回显一个观测(是否"慢")。阈值以同一个块(10 秒)里 RTT 的中位数为基线:
|
||||
/// <c>max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))</c>。基线取自块内数据,所以每个观测只依赖自己所在的块,窗口合并仍然精确;
|
||||
/// 块里的样本少于 <c>minSamples</c> 时基线不可靠,不产生观测。TCP 的长尾通常是丢包后的重传(RTT 聚在 RTO 附近)或队头阻塞。
|
||||
/// </summary>
|
||||
internal sealed class SlowShareOp : MetricOp
|
||||
{
|
||||
private readonly int _minSamples;
|
||||
|
||||
public SlowShareOp(int minSamples)
|
||||
{
|
||||
_minSamples = minSamples;
|
||||
}
|
||||
|
||||
public override string Signature => $"slow_share/{_minSamples}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rtts = new List<(int Row, double Ms)>(c.Count);
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||||
{
|
||||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||||
}
|
||||
|
||||
if (rttUs >= 0)
|
||||
{
|
||||
rtts.Add((i, rttUs / 1000.0));
|
||||
}
|
||||
}
|
||||
|
||||
if (rtts.Count < _minSamples)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double[] sorted = [.. rtts.Select(r => r.Ms).Order()];
|
||||
double baseline = sorted[sorted.Length / 2];
|
||||
double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35));
|
||||
foreach ((int row, double ms) in rtts)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LossOp : MetricOp
|
||||
{
|
||||
private readonly Dir _dir;
|
||||
|
||||
public LossOp(Dir dir)
|
||||
{
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public override string Signature => "loss/" + _dir + "/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || (_dir != Dir.Total && c.T2 is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (_dir)
|
||||
{
|
||||
case Dir.Up:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1);
|
||||
break;
|
||||
case Dir.Down:
|
||||
// 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。
|
||||
if (!c.IsUpLost(i))
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>去除偏移和线性漂移后的单向时延变化:上行用 t2−t1,下行用 t4−t3。</summary>
|
||||
internal sealed class OwdVarOp : MetricOp
|
||||
{
|
||||
private readonly Dir _dir;
|
||||
|
||||
public OwdVarOp(Dir dir)
|
||||
{
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public override string Signature => "owd_var/" + _dir + "/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
RawColumn? a = _dir == Dir.Up ? c.T1 : c.T3;
|
||||
RawColumn? b = _dir == Dir.Up ? c.T2 : c.T4;
|
||||
if (c.T1 is null || a is null || b is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var times = new List<double>(c.Count);
|
||||
var delays = new List<double>(c.Count);
|
||||
var utc = new List<long>(c.Count);
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1.IsValid(i) || !a.IsValid(i) || !b.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
times.Add(c.T1.Values[i]);
|
||||
delays.Add(b.Values[i] - a.Values[i]);
|
||||
utc.Add(c.SentUtcMs(view, i));
|
||||
}
|
||||
|
||||
JitterEmitter.Emit(times, delays, utc, sink);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JitterEmitter
|
||||
{
|
||||
public static void Emit(List<double> timesUs, List<double> delaysUs, List<long> utcMs, IObservationSink sink)
|
||||
{
|
||||
double[]? jitter = ClockDriftFit.Jitter([.. timesUs], [.. delaysUs]);
|
||||
if (jitter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < jitter.Length; i++)
|
||||
{
|
||||
sink.Emit(utcMs[i], jitter[i] / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||||
internal sealed class LossRunsOp : MetricOp
|
||||
{
|
||||
public override string Signature => "loss_runs/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int run = 0;
|
||||
long runStart = 0;
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.IsReplyLost(i))
|
||||
{
|
||||
if (run == 0)
|
||||
{
|
||||
runStart = c.SentUtcMs(view, i);
|
||||
}
|
||||
|
||||
run++;
|
||||
}
|
||||
else if (run > 0)
|
||||
{
|
||||
sink.Emit(runStart, run);
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (run > 0)
|
||||
{
|
||||
sink.Emit(runStart, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
||||
internal sealed class ReorderOp : MetricOp
|
||||
{
|
||||
public override string Signature => "reorder/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || c.Seq is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<long>();
|
||||
long maxSeq = long.MinValue;
|
||||
foreach (int row in c.ArrivalOrder())
|
||||
{
|
||||
long seq = c.Seq.Values[row];
|
||||
if (!seen.Add(seq))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seq < maxSeq)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||||
}
|
||||
|
||||
maxSeq = Math.Max(maxSeq, seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>重复:同一个序号收到不止一次。</summary>
|
||||
internal sealed class DupOp : MetricOp
|
||||
{
|
||||
public override string Signature => "dup/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || c.Seq is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<long>();
|
||||
foreach (int row in c.ArrivalOrder())
|
||||
{
|
||||
if (!seen.Add(c.Seq.Values[row]))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>卡顿:到达间隔超过“期望间隔 + 阈值”。</summary>
|
||||
internal sealed class GapOp : MetricOp
|
||||
{
|
||||
private readonly GapOutput _output;
|
||||
private readonly string _rateParam;
|
||||
private readonly double _thresholdMs;
|
||||
|
||||
public GapOp(GapOutput output, string rateParam, double thresholdMs)
|
||||
{
|
||||
_output = output;
|
||||
_rateParam = rateParam;
|
||||
_thresholdMs = thresholdMs;
|
||||
}
|
||||
|
||||
public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1));
|
||||
double expectedMs = 1000.0 / rate;
|
||||
int[] order = c.ArrivalOrder();
|
||||
for (int i = 1; i < order.Length; i++)
|
||||
{
|
||||
double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0;
|
||||
if (gapMs - expectedMs <= _thresholdMs)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long at = view.ColumnToUtcMs(c.T4, c.T4.Values[order[i]]);
|
||||
sink.Emit(at, _output == GapOutput.Count ? 1 : gapMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每一行一个观测值,用于统计样本量。</summary>
|
||||
internal sealed class RowCountOp : MetricOp
|
||||
{
|
||||
public override string Signature => "row_count/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? time = null;
|
||||
foreach (RawColumn column in stream.Columns)
|
||||
{
|
||||
if (column.Role != ColumnRole.None)
|
||||
{
|
||||
time = column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (time is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (time.IsValid(i))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(time, time.Values[i]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>算子工厂:<c>Ops.Rtt</c>、<c>Ops.Loss(Dir.Up)</c> 等,供 <c>MetricDef</c> 声明使用。</summary>
|
||||
public static class Ops
|
||||
{
|
||||
public static MetricOp Rtt { get; } = new RttOp();
|
||||
|
||||
public static MetricOp SlowShare(int minSamples) => new SlowShareOp(minSamples);
|
||||
|
||||
public static MetricOp Loss(Dir dir) => new LossOp(dir);
|
||||
|
||||
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
|
||||
|
||||
public static MetricOp LossRuns { get; } = new LossRunsOp();
|
||||
|
||||
public static MetricOp Reorder { get; } = new ReorderOp();
|
||||
|
||||
public static MetricOp Dup { get; } = new DupOp();
|
||||
|
||||
public static MetricOp Gap(GapOutput output, string expectedFrom, double thresholdMs) => new GapOp(output, expectedFrom, thresholdMs);
|
||||
|
||||
public static MetricOp RowCount { get; } = new RowCountOp();
|
||||
|
||||
public static MetricOp KernelDelta(string field, string over) => new KernelDeltaOp(field, over);
|
||||
|
||||
public static MetricOp Column(string column, double scale) => new ColumnValueOp(column, scale);
|
||||
|
||||
public static MetricOp BulkRate { get; } = new BulkRateOp();
|
||||
|
||||
public static MetricOp BulkPeak { get; } = new BulkRateOp(windowMs: 1000);
|
||||
|
||||
public static MetricOp PhaseMs(string column) => new PhaseMsOp(column);
|
||||
|
||||
public static MetricOp OutcomeRatio(ConnectOutcome code) => new OutcomeOp(code, asRatio: true);
|
||||
|
||||
public static MetricOp OutcomeCount(ConnectOutcome code) => new OutcomeOp(code, asRatio: false);
|
||||
|
||||
public static MetricOp HoldEvents(HoldEvent holdEvent) => new HoldEventOp(holdEvent);
|
||||
|
||||
public static MetricOp HoldLifetime { get; } = new HoldLifetimeOp();
|
||||
|
||||
public static MetricOp TrainLoss { get; } = new TrainLossOp();
|
||||
|
||||
public static MetricOp TrainJitter { get; } = new TrainJitterOp();
|
||||
|
||||
public static MetricOp TrainRate { get; } = new TrainRateOp();
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Analysis;
|
||||
|
||||
/// <summary>tcp_info 流的辅助:<c>side</c> 为 0 表示 Probe 一侧记录(Probe 时钟),否则为 Agent 一侧。</summary>
|
||||
internal static class TcpInfoTime
|
||||
{
|
||||
public static long ToUtcMs(ChunkView view, RawColumn ts, RawColumn? side, int row) =>
|
||||
side is not null && side.Values[row] == 0 ? view.ProbeToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]);
|
||||
}
|
||||
|
||||
/// <summary>两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 side 分别计算。</summary>
|
||||
internal sealed class KernelDeltaOp : MetricOp
|
||||
{
|
||||
private readonly string _field;
|
||||
private readonly string _over;
|
||||
|
||||
public KernelDeltaOp(string field, string over)
|
||||
{
|
||||
_field = field;
|
||||
_over = over;
|
||||
}
|
||||
|
||||
public override string Signature => $"kernel_delta/{_field}/{_over}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? side = stream.Find("side");
|
||||
RawColumn? field = stream.Find(_field);
|
||||
RawColumn? over = stream.Find(_over);
|
||||
if (ts is null || field is null || over is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var last = new Dictionary<long, (long Field, long Over)>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
long key = side?.Values[i] ?? 0;
|
||||
if (last.TryGetValue(key, out (long Field, long Over) previous))
|
||||
{
|
||||
long dField = Math.Max(0, field.Values[i] - previous.Field);
|
||||
long dOver = Math.Max(0, over.Values[i] - previous.Over);
|
||||
if (dOver > 0)
|
||||
{
|
||||
sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), dField, dOver);
|
||||
}
|
||||
}
|
||||
|
||||
last[key] = (field.Values[i], over.Values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>直接取某一列的值并乘以系数(例如 srtt_us × 0.001 得到毫秒)。</summary>
|
||||
internal sealed class ColumnValueOp : MetricOp
|
||||
{
|
||||
private readonly string _column;
|
||||
private readonly double _scale;
|
||||
|
||||
public ColumnValueOp(string column, double scale)
|
||||
{
|
||||
_column = column;
|
||||
_scale = scale;
|
||||
}
|
||||
|
||||
public override string Signature => $"column/{_column}/{_scale}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? side = stream.Find("side");
|
||||
RawColumn? column = stream.Find(_column);
|
||||
if (ts is null || column is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (column.IsValid(i))
|
||||
{
|
||||
sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), column.Values[i] * _scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量传输的吞吐:每个采样间隔内所有连接的字节增量之和,换算成 Mbit/s。
|
||||
/// <c>windowMs</c> 大于采样间隔时,输出的是滑动窗口(只在窗口填满之后)的平均吞吐:取最大值就是"持续了这么久的最好吞吐",
|
||||
/// 不受慢启动和单个间隔抖动的影响。
|
||||
/// </summary>
|
||||
internal sealed class BulkRateOp : MetricOp
|
||||
{
|
||||
private readonly int _windowMs;
|
||||
|
||||
public BulkRateOp(int windowMs = 0)
|
||||
{
|
||||
_windowMs = windowMs;
|
||||
}
|
||||
|
||||
public override string Signature => $"bulk_rate/{_windowMs}/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? conn = stream.Find("conn");
|
||||
RawColumn? bytes = stream.Find("bytes");
|
||||
if (ts is null || conn is null || bytes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double sampleMs = Math.Max(1, view.ParamNumber("sample_ms", 100));
|
||||
long binUs = (long)(sampleMs * 1000);
|
||||
var last = new Dictionary<long, long>();
|
||||
var bins = new SortedDictionary<long, double>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
long key = conn.Values[i];
|
||||
if (last.TryGetValue(key, out long previous) && bytes.Values[i] >= previous)
|
||||
{
|
||||
long bin = ts.Values[i] / binUs;
|
||||
bins[bin] = bins.GetValueOrDefault(bin) + (bytes.Values[i] - previous);
|
||||
}
|
||||
|
||||
last[key] = bytes.Values[i];
|
||||
}
|
||||
|
||||
if (bins.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long width = Math.Max(1, (long)Math.Round(_windowMs / sampleMs));
|
||||
long first = bins.Keys.First();
|
||||
foreach ((long bin, _) in bins)
|
||||
{
|
||||
if (bin - first + 1 < width)
|
||||
{
|
||||
continue; // 窗口还没填满
|
||||
}
|
||||
|
||||
double total = 0;
|
||||
for (long b = bin - width + 1; b <= bin; b++)
|
||||
{
|
||||
total += bins.GetValueOrDefault(b);
|
||||
}
|
||||
|
||||
double mbps = total * 8 / (width * sampleMs / 1000.0) / 1_000_000;
|
||||
sink.Emit(view.ProbeToUtcMs(bin * binUs), mbps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectOutcome
|
||||
{
|
||||
Ok = 0,
|
||||
Timeout = 1,
|
||||
Reset = 2,
|
||||
TlsAlert = 3,
|
||||
Other = 4,
|
||||
}
|
||||
|
||||
/// <summary>连接结果:作为比率(分子是命中的次数)或计数。</summary>
|
||||
internal sealed class OutcomeOp : MetricOp
|
||||
{
|
||||
private readonly ConnectOutcome _code;
|
||||
private readonly bool _asRatio;
|
||||
|
||||
public OutcomeOp(ConnectOutcome code, bool asRatio)
|
||||
{
|
||||
_code = code;
|
||||
_asRatio = asRatio;
|
||||
}
|
||||
|
||||
public override string Signature => $"outcome/{_code}/{(_asRatio ? "ratio" : "count")}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? outcome = stream.Find("outcome");
|
||||
if (ts is null || outcome is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
bool hit = outcome.Values[i] == (long)_code;
|
||||
long at = view.ColumnToUtcMs(ts, ts.Values[i]);
|
||||
if (_asRatio)
|
||||
{
|
||||
sink.Emit(at, hit ? 1 : 0, 1);
|
||||
}
|
||||
else if (hit)
|
||||
{
|
||||
sink.Emit(at, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>阶段耗时(TCP 建连、TLS 握手):只统计该阶段有值的尝试。</summary>
|
||||
internal sealed class PhaseMsOp : MetricOp
|
||||
{
|
||||
private readonly string _column;
|
||||
|
||||
public PhaseMsOp(string column)
|
||||
{
|
||||
_column = column;
|
||||
}
|
||||
|
||||
public override string Signature => $"phase_ms/{_column}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? column = stream.Find(_column);
|
||||
if (ts is null || column is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (column.IsValid(i))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), column.Values[i] / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum HoldEvent
|
||||
{
|
||||
Open = 0,
|
||||
Heartbeat = 1,
|
||||
Reset = 2,
|
||||
Close = 3,
|
||||
}
|
||||
|
||||
internal sealed class HoldEventOp : MetricOp
|
||||
{
|
||||
private readonly HoldEvent _event;
|
||||
|
||||
public HoldEventOp(HoldEvent holdEvent)
|
||||
{
|
||||
_event = holdEvent;
|
||||
}
|
||||
|
||||
public override string Signature => $"hold_event/{_event}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? ev = stream.Find("event");
|
||||
if (ts is null || ev is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (ev.Values[i] == (long)_event)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连接存活时长:在重置或正常关闭时记录。</summary>
|
||||
internal sealed class HoldLifetimeOp : MetricOp
|
||||
{
|
||||
public override string Signature => "hold_lifetime/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? ev = stream.Find("event");
|
||||
RawColumn? lifetime = stream.Find("lifetime_ms");
|
||||
if (ts is null || ev is null || lifetime is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (ev.Values[i] is (long)HoldEvent.Reset or (long)HoldEvent.Close)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), lifetime.Values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单向 UDP 流(udp_train)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
|
||||
internal sealed class TrainLossOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_loss/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? send = stream.Find("t_send");
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
if (send is null || recv is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(send, send.Values[i]), recv.IsValid(i) ? 0 : 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单向时延变化:t_recv(Probe) − t_send(Agent),去除偏移和漂移。</summary>
|
||||
internal sealed class TrainJitterOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_jitter/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? send = stream.Find("t_send");
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
if (send is null || recv is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var times = new List<double>(stream.Count);
|
||||
var delays = new List<double>(stream.Count);
|
||||
var utc = new List<long>(stream.Count);
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (!recv.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
times.Add(send.Values[i]);
|
||||
delays.Add(recv.Values[i] - send.Values[i]);
|
||||
utc.Add(view.ColumnToUtcMs(send, send.Values[i]));
|
||||
}
|
||||
|
||||
JitterEmitter.Emit(times, delays, utc, sink);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每秒实际收到的速率(Mbit/s)。</summary>
|
||||
internal sealed class TrainRateOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_rate/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
RawColumn? size = stream.Find("size");
|
||||
if (recv is null || size is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bins = new SortedDictionary<long, long>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (!recv.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long second = view.ColumnToUtcMs(recv, recv.Values[i]) / 1000;
|
||||
bins[second] = bins.GetValueOrDefault(second) + size.Values[i];
|
||||
}
|
||||
|
||||
foreach ((long second, long bytes) in bins)
|
||||
{
|
||||
sink.Emit(second * 1000, bytes * 8 / 1_000_000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。</summary>
|
||||
public static class AgentEndpoints
|
||||
{
|
||||
private const string AgentsTag = "admin-agents";
|
||||
private const string TokensTag = "admin-join-tokens";
|
||||
private const string ProbeTag = "admin-probe";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/agents", ListAsync)
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
||||
group.MapGet("/agents/{id}", GetAsync)
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/agents/{id}", PatchAsync)
|
||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
||||
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/join-tokens", ListTokensAsync)
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/probes", CreateProbeAsync)
|
||||
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
|
||||
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
||||
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
|
||||
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
|
||||
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
||||
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
||||
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<AgentDto>([.. (await agents.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoAgentDto);
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, AgentService agents, string id, CancellationToken ct)
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(id), ct);
|
||||
if (view is null)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await agents.PatchAsync(
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TransitionAsync(HttpContext http, string id, Func<NodeId, CancellationToken, ValueTask<Result<AgentView>>> action)
|
||||
{
|
||||
Result<AgentView> result = await action(new NodeId(id), http.RequestAborted);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteAsync(AgentService agents, string id, CancellationToken ct)
|
||||
{
|
||||
await agents.DeleteAsync(new NodeId(id), ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct)
|
||||
{
|
||||
await tokens.RevokeAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
|
||||
|
||||
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, CreateProbeRequest? request, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, ct);
|
||||
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
|
||||
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
|
||||
await probes.GetAsync(id, ct) is { } view
|
||||
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
||||
: Problems.Create(http, MonitorErrors.ProbeNotFound, "probe not found");
|
||||
|
||||
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
if (request.Name is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("name", "is required"));
|
||||
}
|
||||
|
||||
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
|
||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, ct);
|
||||
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeView> result = await probes.RevokeAsync(id, ct);
|
||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
await probes.DeleteAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct)
|
||||
{
|
||||
Result<bool> result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct);
|
||||
if (!result.TryGet(out bool dropped, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
return dropped ? TypedResults.NoContent() : Problems.Create(http, MonitorErrors.ProbeOffline, "this probe has no session with this agent");
|
||||
}
|
||||
|
||||
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
|
||||
new(
|
||||
file.ProbeId.Value, file.PrivateKey, [.. file.Bootstrap.Select(b => new BootstrapAgentDto(b.AgentId.Value, b.Host, b.TcpPort, b.Spki))],
|
||||
new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。</summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string Tag = "admin-auth";
|
||||
|
||||
/// <summary>登录不需要认证:挂在没有守卫的分组上。</summary>
|
||||
public static void MapLogin(RouteGroupBuilder open)
|
||||
{
|
||||
open.MapPost("/auth/login", LoginAsync)
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
}
|
||||
|
||||
public static void MapSession(RouteGroupBuilder guarded)
|
||||
{
|
||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||
{
|
||||
return Problems.RetryAfter(http, new FleetError(MonitorErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
}
|
||||
|
||||
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Cookies.Append(
|
||||
PrincipalResolver.SessionCookie,
|
||||
login.SessionToken,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = options.SecureCookies,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||
});
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
{
|
||||
await auth.LogoutAsync(session, http.RequestAborted);
|
||||
}
|
||||
|
||||
http.Response.Cookies.Delete(PrincipalResolver.SessionCookie, new CookieOptions { Path = "/" });
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<MeDto> Me(HttpContext http)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
return TypedResults.Json(
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
||||
ApiJsonContext.Default.MeDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>原始数据检索与下载、事件、指标重算进度。</summary>
|
||||
public static class DataEndpoints
|
||||
{
|
||||
private const string Tag = "admin-data";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
|
||||
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.monitor.rawchunk)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.monitor.rawchunk").ProducesProblems(401, 403, 404, 410);
|
||||
group.MapGet("/events", ListEventsAsync)
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
|
||||
group.MapGet("/analysis/recompute", RecomputeAsync)
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListChunksAsync(
|
||||
HttpContext http, RawBrowserService raw, string? agent_id, string? kind, string? test_id, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
|
||||
{
|
||||
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||
long? after = null;
|
||||
if (cursor is not null)
|
||||
{
|
||||
if (!long.TryParse(cursor, out long parsed))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||
}
|
||||
|
||||
after = parsed;
|
||||
}
|
||||
|
||||
var query = new RawQuery(
|
||||
agent_id is null ? null : new NodeId(agent_id), null, kind, test_id is null ? null : new TestId(test_id),
|
||||
from?.ToUnixTimeMilliseconds(), to?.ToUnixTimeMilliseconds(), after, size + 1);
|
||||
IReadOnlyList<RawChunkMeta> metas = await raw.ListAsync(query, http.RequestAborted);
|
||||
bool more = metas.Count > size;
|
||||
IReadOnlyList<RawChunkMeta> page = [.. metas.Take(size)];
|
||||
|
||||
// 游标按 start_ts 前进:下一页从最后一项的 start_ts + 1 开始(同一毫秒的多个块在同一页内给出)
|
||||
string? next = more ? (page[^1].StartTs + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
return TypedResults.Json(new PageDto<RawChunkMetaDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoRawChunkMetaDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadChunkAsync(HttpContext http, RawBrowserService raw, string chunk_id)
|
||||
{
|
||||
if (!Guid.TryParse(chunk_id, out Guid id))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.ChunkNotFound, "chunk not found");
|
||||
}
|
||||
|
||||
Result<(RawChunkMeta Meta, byte[] Bytes)> result = await raw.GetAsync(new ChunkId(id), http.RequestAborted);
|
||||
if (!result.TryGet(out (RawChunkMeta Meta, byte[] Bytes) found, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "private, max-age=3600";
|
||||
return TypedResults.File(found.Bytes, "application/vnd.monitor.rawchunk", chunk_id + ".mnrc");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEventsAsync(
|
||||
HttpContext http, EventService events, TimeProvider time, string? agent_id, string? kind, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
|
||||
{
|
||||
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long toMs = to?.ToUnixTimeMilliseconds() ?? now + 1;
|
||||
long fromMs = from?.ToUnixTimeMilliseconds() ?? toMs - Steps.Day;
|
||||
long? before = null;
|
||||
if (cursor is not null)
|
||||
{
|
||||
if (!long.TryParse(cursor, out long parsed))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||
}
|
||||
|
||||
before = parsed;
|
||||
}
|
||||
|
||||
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||
IReadOnlyList<EventRecord> rows = await events.ListAsync(agent_id is null ? null : new NodeId(agent_id), kind, fromMs, toMs, before, size + 1, http.RequestAborted);
|
||||
bool more = rows.Count > size;
|
||||
IReadOnlyList<EventRecord> page = [.. rows.Take(size)];
|
||||
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
return TypedResults.Json(new PageDto<EventDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoEventDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecomputeAsync(OpsService ops, CancellationToken ct)
|
||||
{
|
||||
RecomputeProgress p = ops.RecomputeProgress;
|
||||
return TypedResults.Json(
|
||||
new RecomputeDto(p.State, p.Metrics, p.WindowsDone, p.WindowsTotal, Mappers.Time(p.FromMs), Mappers.Time(p.ToMs), await ops.QueueLengthAsync(ct)),
|
||||
ApiJsonContext.Default.RecomputeDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>API 令牌、站点设置与保留期、用户。令牌明文只在创建的响应里出现一次。</summary>
|
||||
public static class SystemEndpoints
|
||||
{
|
||||
private const string TokensTag = "admin-api-tokens";
|
||||
private const string SettingsTag = "admin-settings";
|
||||
private const string UsersTag = "admin-users";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/api-tokens", ListTokensAsync)
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/settings", ListSettingsAsync)
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
|
||||
group.MapPatch("/settings", PatchSettingsAsync)
|
||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
|
||||
|
||||
group.MapGet("/users", ListUsersAsync)
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new CreatedApiTokenDto(Mappers.ToDto(created.Record), created.Plaintext), ApiJsonContext.Default.CreatedApiTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(AuthService auth, string id, CancellationToken ct)
|
||||
{
|
||||
await auth.RevokeApiTokenAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSettingsAsync(SettingsService settings, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<SettingDto>([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto);
|
||||
|
||||
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, Dictionary<string, JsonElement> changes)
|
||||
{
|
||||
Result<IReadOnlyList<SettingView>> result = await settings.PatchAsync(changes, http.RequestAborted);
|
||||
return result.TryGet(out IReadOnlyList<SettingView>? views, out FleetError? error)
|
||||
? TypedResults.Json(new PageDto<SettingDto>([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListUsersAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<UserDto>([.. (await auth.ListUsersAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoUserDto);
|
||||
|
||||
private static async Task<IResult> CreateUserAsync(HttpContext http, AuthService auth, CreateUserRequest request)
|
||||
{
|
||||
Result<UserRecord> result = await auth.CreateUserAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out UserRecord? user, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/admin/users";
|
||||
return TypedResults.Json(Mappers.ToDto(user), ApiJsonContext.Default.UserDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePasswordAsync(HttpContext http, AuthService auth, ChangePasswordRequest request, string id)
|
||||
{
|
||||
Result<bool> result = await auth.ChangePasswordAsync(id, request.Password ?? "", http.RequestAborted);
|
||||
return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>任务(线路模板、单个 Agent 的覆盖、目标任务)与按需测试。参数按 Kind 的 ParamSpec 校验,并检查带宽预算。</summary>
|
||||
public static class TaskEndpoints
|
||||
{
|
||||
private const string TasksTag = "admin-tasks";
|
||||
private const string TestsTag = "admin-tests";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/tasks", ListAsync)
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/tasks", CreateAsync)
|
||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
|
||||
group.MapGet("/tasks/{id}", GetAsync)
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
|
||||
group.MapDelete("/tasks/{id}", DeleteAsync)
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
|
||||
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<TaskRecord> all = await tasks.ListAsync(ct);
|
||||
if (agent_id is not null)
|
||||
{
|
||||
all = all.Where(t => t.AgentId?.Value == agent_id);
|
||||
}
|
||||
|
||||
if (scope is not null)
|
||||
{
|
||||
all = all.Where(t => t.Scope == scope);
|
||||
}
|
||||
|
||||
return TypedResults.Json(new PageDto<TaskDto>([.. all.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync(HttpContext http, TaskService tasks, CreateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
Result<TaskRecord> result = await tasks.CreateAsync(
|
||||
new TaskInput(
|
||||
request.Scope ?? "", request.Kind ?? "", request.Target, request.Params, request.Schedule, request.Enabled,
|
||||
request.AgentId is null ? null : new NodeId(request.AgentId), request.BaseTaskId is null ? null : new TaskId(request.BaseTaskId)),
|
||||
ct);
|
||||
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/admin/tasks/" + task.Id.Value;
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, TaskService tasks, string id, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? task = await tasks.GetAsync(new TaskId(id), ct);
|
||||
if (task is null)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.TaskNotFound, "task not found");
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, TaskService tasks, PatchTaskRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<TaskRecord> result = await tasks.UpdateAsync(new TaskId(id), new TaskPatch(request.Params, request.Schedule, request.Enabled, request.Target), Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteAsync(TaskService tasks, string id, CancellationToken ct)
|
||||
{
|
||||
await tasks.DeleteAsync(new TaskId(id), ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartTestAsync(HttpContext http, TestService tests, LineService lines, StartTestRequestDto request, string line_id, CancellationToken ct)
|
||||
{
|
||||
Result<LineView> found = await lines.ResolveAsync(line_id, ct);
|
||||
if (!found.TryGet(out LineView? line, out FleetError? lineError))
|
||||
{
|
||||
return Problems.From(http, lineError);
|
||||
}
|
||||
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct);
|
||||
if (!result.TryGet(out TestRecord? test, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/public/tests/" + test.Id.Value;
|
||||
return TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto, statusCode: StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct)
|
||||
{
|
||||
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
|
||||
NodeId agent;
|
||||
ProbeId? probe = null;
|
||||
if (LineKey.TryParse(line_id, out LineKey key))
|
||||
{
|
||||
agent = key.Agent;
|
||||
probe = key.Probe;
|
||||
}
|
||||
else
|
||||
{
|
||||
agent = new NodeId(line_id);
|
||||
}
|
||||
|
||||
return TypedResults.Json(
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null),
|
||||
ApiJsonContext.Default.PageDtoTestDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelAsync(HttpContext http, TestService tests, string id, CancellationToken ct)
|
||||
{
|
||||
Result<TestRecord> result = await tests.CancelAsync(new TestId(id), ct);
|
||||
return result.TryGet(out TestRecord? test, out FleetError? error)
|
||||
? TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 <see cref="MapMonitorApi"/> 里加一行。
|
||||
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次(<see cref="ErrorCodeHttpMap"/>)。
|
||||
/// </summary>
|
||||
public static class ApiModule
|
||||
{
|
||||
public static IServiceCollection AddMonitorApi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SseLimiter>();
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
JsonSerializerOptions json = options.SerializerOptions;
|
||||
json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
json.DictionaryKeyPolicy = null;
|
||||
json.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
|
||||
json.Converters.Add(new Rfc3339Converter());
|
||||
json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
|
||||
});
|
||||
|
||||
// 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id
|
||||
services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
|
||||
{
|
||||
int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode;
|
||||
context.ProblemDetails.Extensions["code"] = DefaultCode(status);
|
||||
context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
|
||||
if (status >= 500)
|
||||
{
|
||||
context.ProblemDetails.Detail = null;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddOpenApi("v1");
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跨域(可选):只对 <c>/api/public</c> 和 <c>/api/admin</c> 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域),
|
||||
/// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseMonitorCors(this IApplicationBuilder app, string? origins)
|
||||
{
|
||||
string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (allowed.Length == 0)
|
||||
{
|
||||
return app;
|
||||
}
|
||||
|
||||
bool any = allowed.Contains("*");
|
||||
return app.Use(async (http, next) =>
|
||||
{
|
||||
string? origin = http.Request.Headers.Origin;
|
||||
if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await next(http);
|
||||
return;
|
||||
}
|
||||
|
||||
http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin;
|
||||
http.Response.Headers.Vary = "Origin";
|
||||
http.Response.Headers.AccessControlExposeHeaders = "Retry-After";
|
||||
if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method"))
|
||||
{
|
||||
http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
|
||||
http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token";
|
||||
http.Response.Headers.AccessControlMaxAge = "600";
|
||||
http.Response.StatusCode = StatusCodes.Status204NoContent;
|
||||
return;
|
||||
}
|
||||
|
||||
await next(http);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。</summary>
|
||||
public static IApplicationBuilder UseMonitorApi(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.Use(PrincipalResolver.ResolveAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapMonitorApi(this IEndpointRouteBuilder app)
|
||||
{
|
||||
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
|
||||
QueryEndpoints.Map(publicGroup);
|
||||
LiveEndpoints.Map(publicGroup);
|
||||
HostEndpoints.Map(publicGroup);
|
||||
CatalogEndpoints.Map(publicGroup);
|
||||
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
AuthEndpoints.MapLogin(admin);
|
||||
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
|
||||
AuthEndpoints.MapSession(guarded);
|
||||
AgentEndpoints.Map(guarded);
|
||||
TaskEndpoints.Map(guarded);
|
||||
DataEndpoints.Map(guarded);
|
||||
SystemEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string DefaultCode(int status) => status switch
|
||||
{
|
||||
400 => "validation_failed",
|
||||
401 => "unauthorized",
|
||||
403 => "forbidden",
|
||||
404 => "not_found",
|
||||
405 => "method_not_allowed",
|
||||
413 => "payload_too_large",
|
||||
415 => "unsupported_media_type",
|
||||
429 => "too_many_requests",
|
||||
503 => "storage_unavailable",
|
||||
_ => "internal_error",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>并发控制:配置类资源返回 ETag(行版本),PATCH/DELETE 支持 If-Match,不匹配时返回 412。</summary>
|
||||
internal static class Concurrency
|
||||
{
|
||||
public static string ETag(long rowVersion) => "\"" + rowVersion.ToString(CultureInfo.InvariantCulture) + "\"";
|
||||
|
||||
public static void SetETag(HttpContext http, long rowVersion) => http.Response.Headers.ETag = ETag(rowVersion);
|
||||
|
||||
/// <summary>没有 If-Match(或 <c>*</c>)按“最后写入者获胜”处理,返回 null;写成不合法的值返回 -1(一定不匹配)。</summary>
|
||||
public static long? ExpectedRowVersion(HttpContext http)
|
||||
{
|
||||
string? header = http.Request.Headers.IfMatch;
|
||||
if (string.IsNullOrWhiteSpace(header) || header.Trim() == "*")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = header.Trim().Trim('"');
|
||||
return long.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out long version) ? version : -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
public static class EndpointConventions
|
||||
{
|
||||
/// <summary>每个端点都必须有 operationId(snake_case,资源_动作)、summary 和 tag。</summary>
|
||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
||||
|
||||
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||
builder.Produces<T>(status);
|
||||
|
||||
/// <summary>登记该端点可能返回的错误状态(problem+json),让 OpenAPI 文档完整。</summary>
|
||||
public static RouteHandlerBuilder ProducesProblems(this RouteHandlerBuilder builder, params int[] statuses)
|
||||
{
|
||||
foreach (int status in statuses)
|
||||
{
|
||||
builder.Produces<ProblemDto>(status, Problems.ContentType);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
public static class HttpContextPrincipalExtensions
|
||||
{
|
||||
private const string Key = "monitor.principal";
|
||||
|
||||
public static Principal GetPrincipal(this HttpContext http) =>
|
||||
http.Items.TryGetValue(Key, out object? value) && value is Principal principal ? principal : Principal.Anonymous;
|
||||
|
||||
public static void SetPrincipal(this HttpContext http, Principal principal) => http.Items[Key] = principal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 认证:<c>Authorization: Bearer <api_token></c> 或会话 Cookie。作为中间件运行,只解析身份;授权由各层的守卫决定。
|
||||
/// </summary>
|
||||
public static class PrincipalResolver
|
||||
{
|
||||
public const string SessionCookie = "monitor_session";
|
||||
public const string CsrfHeader = "X-CSRF-Token";
|
||||
|
||||
public static async Task ResolveAsync(HttpContext http, Func<Task> next)
|
||||
{
|
||||
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
|
||||
Principal? principal = null;
|
||||
string? header = http.Request.Headers.Authorization;
|
||||
if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted);
|
||||
}
|
||||
else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session))
|
||||
{
|
||||
principal = await auth.AuthenticateSessionAsync(session, http.RequestAborted);
|
||||
}
|
||||
|
||||
http.SetPrincipal(principal ?? Principal.Anonymous);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class AdminGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
{
|
||||
return principal.CanRead
|
||||
? Problems.Create(http, MonitorErrors.Forbidden, "the token scope does not allow this operation")
|
||||
: Problems.Create(http, MonitorErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
if (principal.Kind == PrincipalKind.Session && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||
{
|
||||
string? csrf = http.Request.Headers[PrincipalResolver.CsrfHeader];
|
||||
if (csrf is null || principal.CsrfToken is null || !AuthService.FixedTimeEquals(csrf, principal.CsrfToken))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.CsrfFailed, "a valid X-CSRF-Token header is required");
|
||||
}
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Server;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>应用层模型 → REST DTO。public 层的 DTO 只暴露可以公开的字段。</summary>
|
||||
public static class Mappers
|
||||
{
|
||||
public static DateTimeOffset Time(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms);
|
||||
|
||||
public static DateTimeOffset? Time(long? ms) => ms is { } v ? DateTimeOffset.FromUnixTimeMilliseconds(v) : null;
|
||||
|
||||
public static string StatusName(NodeStatus status) => status switch
|
||||
{
|
||||
NodeStatus.Active => "active",
|
||||
NodeStatus.Disabled => "disabled",
|
||||
_ => "pending",
|
||||
};
|
||||
|
||||
public static TestSummaryDto? Summary(TestRecord? test) =>
|
||||
test is null ? null : new TestSummaryDto(test.Id.Value, test.Plan, test.Status, Time(test.RequestedAtMs), Time(test.FinishedAtMs));
|
||||
|
||||
public static SummaryDto ToDto(SummaryResult summary) =>
|
||||
new(summary.Values, summary.Samples.ToDictionary(s => s.Key, s => checked((long)s.Value)), summary.Coverage);
|
||||
|
||||
public static EventDto ToDto(EventRecord e) => new(e.Id, e.AgentId?.Value, e.Kind, Time(e.AtMs), e.Detail);
|
||||
|
||||
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));
|
||||
|
||||
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.UptimeSeconds, Time(h.AtUtcMs));
|
||||
|
||||
public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs));
|
||||
|
||||
public static ProbeDto ToDto(ProbeView v) =>
|
||||
new(
|
||||
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked,
|
||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs)))]);
|
||||
|
||||
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
||||
new(
|
||||
r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded,
|
||||
r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial),
|
||||
[.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions);
|
||||
|
||||
public static TestDto ToDto(TestDetail detail)
|
||||
{
|
||||
TestRecord t = detail.Test;
|
||||
return new TestDto(
|
||||
t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger,
|
||||
Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result));
|
||||
}
|
||||
|
||||
public static TestDto ToDto(TestRecord t) =>
|
||||
new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null);
|
||||
|
||||
public static TestResultDto ToDto(TestResultView r) =>
|
||||
new(
|
||||
r.Metrics,
|
||||
r.Scores.ToDictionary(s => s.Key, s => new ScoreDto(s.Value.Score, s.Value.Version, s.Value.Estimated, [.. s.Value.Dimensions.Select(d => new DimensionDto(d.Id, d.Label, d.Weight, d.Value, d.Score))], s.Value.Caveats)),
|
||||
r.Series,
|
||||
[.. r.Steps.Select(s => new TestStepDto(s.Index, s.TargetMbps, s.LossPct, s.JitterP95Ms, s.DeliveredMbps))],
|
||||
r.Caveats);
|
||||
|
||||
public static AgentDto ToDto(AgentView view)
|
||||
{
|
||||
AgentRecord a = view.Agent;
|
||||
LocalLimitsDto? limits = null;
|
||||
if (a.LocalLimitsJson is { } json)
|
||||
{
|
||||
try
|
||||
{
|
||||
LocalLimits? parsed = JsonSerializer.Deserialize(json, ApiJsonContext.Default.LocalLimits);
|
||||
limits = parsed is null ? null : new LocalLimitsDto(parsed.MaxTotalMbps, parsed.MaxTotalPps, parsed.AllowedTargetCidrs, parsed.MaxConcurrentTests);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 无法解析就当作没有上报
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<RejectedDto> rejected = [];
|
||||
if (a.RejectedJson is { } rejectedJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
rejected = [.. (JsonSerializer.Deserialize(rejectedJson, ApiJsonContext.Default.ListRejectedItem) ?? []).Select(r => new RejectedDto(r.Id, r.Reason))];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
HeartbeatDto? heartbeat = view.Heartbeat is { } h
|
||||
? 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.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs));
|
||||
}
|
||||
|
||||
public static TaskDto ToDto(TaskRecord t) =>
|
||||
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
||||
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs));
|
||||
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
|
||||
|
||||
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
||||
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
||||
|
||||
public static SettingDto ToDto(SettingView s) => new(s.Key, s.Type, s.Value, s.Default, s.Description);
|
||||
|
||||
public static UserDto ToDto(UserRecord u) => new(u.Id, u.Username, Time(u.CreatedAtMs));
|
||||
|
||||
// ── 目录 ──
|
||||
|
||||
public static KindDto ToDto(KindSpec k) =>
|
||||
new(
|
||||
k.Id, k.Title, k.Description,
|
||||
[.. Enum.GetValues<Scope>().Where(s => k.Scopes.HasFlag(s)).Select(s => s.ToString().ToLowerInvariant())],
|
||||
new BlocksDto(k.Blocks.Transport, k.Blocks.Pacer, k.Blocks.Payload, k.Blocks.Behavior, k.Blocks.Samplers),
|
||||
[.. k.Params.Select(ToDto)],
|
||||
[.. k.Streams.Select(s => new StreamDto(s.Name, [.. s.Columns.Select(c => new ColumnDto(c.Name, RawChunkCodec.TypeName(c.Type), c.Enc.ToString().ToLowerInvariant(), c.Nullable, RoleName(c.Role)))]))]);
|
||||
|
||||
public static ParamDto ToDto(ParamSpec p) => p switch
|
||||
{
|
||||
IntParam i => new ParamDto(i.Name, "int", i.DefaultValue, i.Min, i.Max, i.Unit, null, i.Description),
|
||||
NumberParam n => new ParamDto(n.Name, "number", n.DefaultValue, n.Min, n.Max, n.Unit, null, n.Description),
|
||||
BoolParam b => new ParamDto(b.Name, "bool", b.DefaultValue, null, null, null, null, b.Description),
|
||||
EnumParam e => new ParamDto(e.Name, "enum", e.DefaultValue, null, null, null, e.Values, e.Description),
|
||||
_ => new ParamDto(p.Name, "unknown", p.DefaultValue, null, null, null, null, p.Description),
|
||||
};
|
||||
|
||||
public static PlanDto ToDto(PlanSpec p) =>
|
||||
new(p.Id, p.Title, p.Description, [.. p.Phases.Select(ph => new PhaseDto(ph.Kind, ph.DurationSeconds, JsonSerializer.SerializeToElement(ph.Overrides.ToDictionary(o => o.Key, o => o.Value), ApiJsonContext.Default.SettingsPatch)))]);
|
||||
|
||||
public static MetricDto ToDto(MetricDef def, MetricExport export) =>
|
||||
new(
|
||||
export.Id, def.Id, def.Kind.ToString().ToLowerInvariant(), Metric.StatName(export.Stat), def.Label, def.Description,
|
||||
export.Unit.ToString().ToLowerInvariant(), def.Better.ToString().ToLowerInvariant(), export.Good, export.Poor, def.Fingerprint, def.From);
|
||||
|
||||
public static ModelDto ToDto(IServiceModel m) => new(m.Id, m.Title, m.Version, m.Needs);
|
||||
|
||||
private static string? RoleName(ColumnRole role) => role switch
|
||||
{
|
||||
ColumnRole.TsProbe => "ts_probe",
|
||||
ColumnRole.TsAgent => "ts_agent",
|
||||
ColumnRole.TsLocal => "ts_local",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>错误码到 HTTP 状态的映射:只在 Api 层做一次。</summary>
|
||||
public static class ErrorCodeHttpMap
|
||||
{
|
||||
// 必须声明在 s_map 之前:静态字段按文本顺序初始化,BuildMap 会用到它
|
||||
public static readonly ErrorCode NotImplemented = new("not_implemented");
|
||||
|
||||
private static readonly Dictionary<string, int> s_map = BuildMap();
|
||||
|
||||
public static int StatusOf(ErrorCode code) => s_map.GetValueOrDefault(code.Value, StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static string TitleOf(int status) => status switch
|
||||
{
|
||||
400 => "Validation failed",
|
||||
401 => "Unauthorized",
|
||||
403 => "Forbidden",
|
||||
404 => "Not found",
|
||||
409 => "Conflict",
|
||||
410 => "Gone",
|
||||
412 => "Precondition failed",
|
||||
413 => "Payload too large",
|
||||
422 => "Unprocessable request",
|
||||
429 => "Too many requests",
|
||||
501 => "Not implemented",
|
||||
503 => "Service unavailable",
|
||||
_ => "Internal error",
|
||||
};
|
||||
|
||||
private static Dictionary<string, int> BuildMap()
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
void Add(int status, params ErrorCode[] codes)
|
||||
{
|
||||
foreach (ErrorCode code in codes)
|
||||
{
|
||||
map[code.Value] = status;
|
||||
}
|
||||
}
|
||||
|
||||
Add(400, MonitorErrors.ValidationFailed, MonitorErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||
Add(401, MonitorErrors.Unauthorized, MonitorErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||
Add(403, MonitorErrors.Forbidden, MonitorErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(
|
||||
404, MonitorErrors.NotFound, MonitorErrors.AgentNotFound, MonitorErrors.ProbeNotFound, MonitorErrors.TaskNotFound, MonitorErrors.TestNotFound, MonitorErrors.ChunkNotFound,
|
||||
MonitorErrors.TokenNotFound, MonitorErrors.UnknownKind, MonitorErrors.UnknownPlan, FleetErrors.NotFound);
|
||||
Add(409, MonitorErrors.NameConflict, MonitorErrors.ProbeRevoked, MonitorErrors.ProbeOffline, MonitorErrors.TestBusy, MonitorErrors.AgentNotActive, MonitorErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline);
|
||||
Add(410, MonitorErrors.RawExpired, MonitorErrors.JoinTokenExpired, FleetErrors.JoinTokenExpired);
|
||||
Add(412, MonitorErrors.PreconditionFailed);
|
||||
Add(413, MonitorErrors.PayloadTooLarge);
|
||||
Add(422, MonitorErrors.ProbeRequired, MonitorErrors.RangeTooLarge, MonitorErrors.InvalidStep, MonitorErrors.InvalidRange, MonitorErrors.UnknownMetric, MonitorErrors.UnknownPreset, MonitorErrors.TooManyMetrics, FleetErrors.Unsupported);
|
||||
Add(429, MonitorErrors.TooManyRequests, MonitorErrors.TooManyStreams);
|
||||
Add(503, MonitorErrors.StorageUnavailable, FleetErrors.Timeout);
|
||||
map[NotImplemented.Value] = 501;
|
||||
return map;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>所有 4xx/5xx 响应都使用 problem+json;5xx 不泄露异常信息,只提供 trace_id。</summary>
|
||||
public static class Problems
|
||||
{
|
||||
public const string ContentType = "application/problem+json";
|
||||
|
||||
public static IResult From(HttpContext http, FleetError error)
|
||||
{
|
||||
int status = ErrorCodeHttpMap.StatusOf(error.Code);
|
||||
IReadOnlyDictionary<string, string[]>? fields = error is ValidationError v
|
||||
? v.Fields.ToDictionary(f => f.Key, f => new[] { f.Value })
|
||||
: null;
|
||||
return Create(http, status, error.Code.Value, status >= 500 ? null : error.Message, fields);
|
||||
}
|
||||
|
||||
public static IResult Create(HttpContext http, ErrorCode code, string? detail) =>
|
||||
Create(http, ErrorCodeHttpMap.StatusOf(code), code.Value, detail, null);
|
||||
|
||||
public static IResult Create(HttpContext http, int status, string code, string? detail, IReadOnlyDictionary<string, string[]>? errors = null)
|
||||
{
|
||||
string? trace = Activity.Current?.Id ?? http.TraceIdentifier;
|
||||
var problem = new ProblemDto("about:blank", ErrorCodeHttpMap.TitleOf(status), status, code, detail, trace, errors);
|
||||
return TypedResults.Json(problem, ApiJsonContext.Default.ProblemDto, ContentType, status);
|
||||
}
|
||||
|
||||
public static IResult RetryAfter(HttpContext http, FleetError error, int seconds)
|
||||
{
|
||||
http.Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return From(http, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
internal static class QueryHelpers
|
||||
{
|
||||
/// <summary>查询的统一超时(默认 10 s):超时返回 503,而不是让请求一直挂着。</summary>
|
||||
public static async Task<IResult> GuardedAsync(HttpContext http, Func<CancellationToken, Task<IResult>> work)
|
||||
{
|
||||
BackendOptions options = http.RequestServices.GetRequiredService<BackendOptions>();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted);
|
||||
timeout.CancelAfter(options.QueryTimeout);
|
||||
try
|
||||
{
|
||||
return await work(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!http.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.StorageUnavailable, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 缓存策略:已经结束的绝对区间(<c>to</c> 早于当前时间 − 2 分钟)可以永久缓存,并带 ETag;相对时间段不缓存。
|
||||
/// 返回非空时表示命中 If-None-Match,直接返回 304。
|
||||
/// </summary>
|
||||
public static IResult? ApplyCaching(HttpContext http, QueryRange range, long nowMs, string material)
|
||||
{
|
||||
bool closed = !range.IsRelative && range.RequestedToMs < nowMs - (2 * Steps.Minute);
|
||||
if (!closed)
|
||||
{
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return null;
|
||||
}
|
||||
|
||||
string etag = ETagOf(http.Request.Path + http.Request.QueryString.Value + material);
|
||||
http.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||
http.Response.Headers.ETag = etag;
|
||||
return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null;
|
||||
}
|
||||
|
||||
/// <summary>目录:<c>max-age=300</c>,并带 ETag。</summary>
|
||||
public static IResult? ApplyCatalogCaching(HttpContext http, string material)
|
||||
{
|
||||
string etag = ETagOf(http.Request.Path + material);
|
||||
http.Response.Headers.CacheControl = "public, max-age=300";
|
||||
http.Response.Headers.ETag = etag;
|
||||
return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null;
|
||||
}
|
||||
|
||||
public static string ETagOf(string material) =>
|
||||
"\"" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..32] + "\"";
|
||||
|
||||
private static bool MatchesIfNoneMatch(HttpContext http, string etag)
|
||||
{
|
||||
foreach (string? candidate in http.Request.Headers.IfNoneMatch)
|
||||
{
|
||||
if (candidate is not null && candidate.Split(',', StringSplitOptions.TrimEntries).Contains(etag))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>线路 id(<c>agent~probe</c>,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。</summary>
|
||||
public static async ValueTask<Result<LineView>> FindVisibleLineAsync(HttpContext http, LineService lines, string id, CancellationToken ct)
|
||||
{
|
||||
Result<LineView> found = await lines.ResolveAsync(id, ct);
|
||||
if (!found.TryGet(out LineView? line, out FleetError? error))
|
||||
{
|
||||
return Result<LineView>.Fail(error);
|
||||
}
|
||||
|
||||
return line.Agent.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Agent.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Probe.RevokedAtMs is null
|
||||
? found
|
||||
: Result<LineView>.Fail(MonitorErrors.AgentNotFound, "line not found");
|
||||
}
|
||||
|
||||
/// <summary>找到可以展示的线路:已激活,并且(对外可见,或者访问者已认证)。否则 404(不暴露被隐藏的线路存在与否)。</summary>
|
||||
public static async ValueTask<Result<AgentView>> FindVisibleAgentAsync(HttpContext http, AgentService agents, string id, CancellationToken ct)
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(id), ct);
|
||||
return view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))
|
||||
? Result<AgentView>.Ok(view)
|
||||
: Result<AgentView>.Fail(MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
public sealed record LoginRequest(string Username, string Password);
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
|
||||
public sealed record RejectedDto(string Id, string Reason);
|
||||
|
||||
public sealed record HeartbeatDto(int Sessions, long PpsIn, long PpsOut, long DroppedInvalid, long DroppedRateLimited, long QueueBytes, DateTimeOffset At);
|
||||
|
||||
public sealed record AgentDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string Status,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
IReadOnlyList<ConnectedProbeDto> Probes,
|
||||
HostDto? Host,
|
||||
string PublicHost,
|
||||
int ProbeTcpPort,
|
||||
IReadOnlyList<int> ProbeUdpPorts,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool PublicVisible,
|
||||
IReadOnlyList<string> Kinds,
|
||||
string? AgentVersion,
|
||||
LocalLimitsDto? LocalLimits,
|
||||
long TaskVersion,
|
||||
long AppliedTaskVersion,
|
||||
IReadOnlyList<RejectedDto> Rejected,
|
||||
HeartbeatDto? Heartbeat,
|
||||
int EndpointChanges,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastSeenAt);
|
||||
|
||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible);
|
||||
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, IReadOnlyList<ProbeConnectionDto> Connections);
|
||||
|
||||
public sealed record BootstrapAgentDto(string AgentId, string Host, int TcpPort, string Spki);
|
||||
|
||||
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
|
||||
|
||||
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
|
||||
public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, IReadOnlyList<BootstrapAgentDto> Bootstrap, ProbeLimitsDto Limits);
|
||||
|
||||
public sealed record CreateProbeRequest(string? Name);
|
||||
|
||||
public sealed record PatchProbeRequest(string? Name);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
string Scope,
|
||||
string? AgentId,
|
||||
string? BaseTaskId,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement> Params,
|
||||
string Schedule,
|
||||
bool Enabled,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record CreateTaskRequest(
|
||||
string Scope,
|
||||
string Kind,
|
||||
string? Target,
|
||||
Dictionary<string, JsonElement>? Params,
|
||||
string? Schedule,
|
||||
bool? Enabled,
|
||||
string? AgentId,
|
||||
string? BaseTaskId);
|
||||
|
||||
public sealed record PatchTaskRequest(Dictionary<string, JsonElement>? Params, string? Schedule, bool? Enabled, string? Target);
|
||||
|
||||
public sealed record StartTestRequestDto(string Plan, Dictionary<string, JsonElement>? Params);
|
||||
|
||||
public sealed record RawChunkMetaDto(
|
||||
string ChunkId,
|
||||
string AgentId,
|
||||
string Series,
|
||||
string Kind,
|
||||
string? TaskId,
|
||||
string? TestId,
|
||||
int? Phase,
|
||||
DateTimeOffset Start,
|
||||
int DurationMs,
|
||||
bool Partial,
|
||||
int Bytes);
|
||||
|
||||
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
|
||||
|
||||
public sealed record CreateApiTokenRequest(string Name, string Scope);
|
||||
|
||||
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
||||
|
||||
public sealed record SettingDto(string Key, string Type, string Value, string Default, string Description);
|
||||
|
||||
public sealed record UserDto(string Id, string Username, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record CreateUserRequest(string Username, string Password);
|
||||
|
||||
public sealed record ChangePasswordRequest(string Password);
|
||||
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// REST 的 JSON 源生成上下文(snake_case,字段始终存在、未知值写 null,枚举与时间的格式见数据表示规则)。
|
||||
/// 新增 DTO 时在这里登记一行,不使用反射。
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = JsonKnownNamingPolicy.Unspecified,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = [typeof(Rfc3339Converter)],
|
||||
NumberHandling = JsonNumberHandling.Strict)]
|
||||
[JsonSerializable(typeof(ProblemDto))]
|
||||
[JsonSerializable(typeof(HealthDto))]
|
||||
[JsonSerializable(typeof(IdDto))]
|
||||
[JsonSerializable(typeof(OverviewDto))]
|
||||
[JsonSerializable(typeof(SeriesDto))]
|
||||
[JsonSerializable(typeof(CompareDto))]
|
||||
[JsonSerializable(typeof(DistributionDto))]
|
||||
[JsonSerializable(typeof(HeatmapDto))]
|
||||
[JsonSerializable(typeof(LineDto))]
|
||||
[JsonSerializable(typeof(TestDto))]
|
||||
[JsonSerializable(typeof(PageDto<TestDto>))]
|
||||
[JsonSerializable(typeof(PageDto<KindDto>))]
|
||||
[JsonSerializable(typeof(PageDto<PlanDto>))]
|
||||
[JsonSerializable(typeof(PageDto<MetricDto>))]
|
||||
[JsonSerializable(typeof(PageDto<PresetDto>))]
|
||||
[JsonSerializable(typeof(PageDto<ModelDto>))]
|
||||
[JsonSerializable(typeof(LiveNodeDto))]
|
||||
[JsonSerializable(typeof(LiveTestDto))]
|
||||
[JsonSerializable(typeof(LoginRequest))]
|
||||
[JsonSerializable(typeof(LoginResponse))]
|
||||
[JsonSerializable(typeof(MeDto))]
|
||||
[JsonSerializable(typeof(AgentDto))]
|
||||
[JsonSerializable(typeof(PageDto<AgentDto>))]
|
||||
[JsonSerializable(typeof(AgentPatchRequest))]
|
||||
[JsonSerializable(typeof(JoinTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
||||
[JsonSerializable(typeof(CreatedJoinTokenDto))]
|
||||
[JsonSerializable(typeof(ProbeIdentityDto))]
|
||||
[JsonSerializable(typeof(CreateProbeRequest))]
|
||||
[JsonSerializable(typeof(PatchProbeRequest))]
|
||||
[JsonSerializable(typeof(ProbeDto))]
|
||||
[JsonSerializable(typeof(PageDto<HostItemDto>))]
|
||||
[JsonSerializable(typeof(HostSeriesDto))]
|
||||
[JsonSerializable(typeof(LiveHostDto))]
|
||||
[JsonSerializable(typeof(PageDto<ProbeDto>))]
|
||||
[JsonSerializable(typeof(TaskDto))]
|
||||
[JsonSerializable(typeof(PageDto<TaskDto>))]
|
||||
[JsonSerializable(typeof(CreateTaskRequest))]
|
||||
[JsonSerializable(typeof(PatchTaskRequest))]
|
||||
[JsonSerializable(typeof(StartTestRequestDto))]
|
||||
[JsonSerializable(typeof(RawChunkMetaDto))]
|
||||
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
||||
[JsonSerializable(typeof(PageDto<EventDto>))]
|
||||
[JsonSerializable(typeof(ApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||
[JsonSerializable(typeof(PageDto<UserDto>))]
|
||||
[JsonSerializable(typeof(UserDto))]
|
||||
[JsonSerializable(typeof(CreateUserRequest))]
|
||||
[JsonSerializable(typeof(ChangePasswordRequest))]
|
||||
[JsonSerializable(typeof(RecomputeDto))]
|
||||
[JsonSerializable(typeof(Monitor.Protocol.LocalLimits))]
|
||||
[JsonSerializable(typeof(List<Fleet.Rpc.RejectedItem>))]
|
||||
public sealed partial class ApiJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>时间点统一使用 RFC 3339 UTC,以 Z 结尾,精确到毫秒(唯一例外:列式时序里的时间轴 <c>t</c> 是 Unix 毫秒)。</summary>
|
||||
public sealed class Rfc3339Converter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
private const string Format = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'";
|
||||
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
DateTimeOffset.Parse(reader.GetString() ?? throw new JsonException("timestamp must be a string"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>RFC 9457 problem+json,附加 <c>code</c>(客户端判断的唯一依据)、<c>trace_id</c> 和 400 时的逐字段 <c>errors</c>。</summary>
|
||||
public sealed record ProblemDto(
|
||||
string Type,
|
||||
string Title,
|
||||
int Status,
|
||||
string Code,
|
||||
string? Detail,
|
||||
string? TraceId,
|
||||
IReadOnlyDictionary<string, string[]>? Errors);
|
||||
|
||||
public sealed record PageDto<T>(IReadOnlyList<T> Items, string? NextCursor);
|
||||
|
||||
public sealed record IdDto(string Id);
|
||||
|
||||
public sealed record HealthDto(string Status, bool Database, bool RawDirectory);
|
||||
@@ -0,0 +1,175 @@
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>汇总:整个时间段由累加器精确合并得出的值(不是各点的平均)。</summary>
|
||||
public sealed record SummaryDto(IReadOnlyDictionary<string, double?> Metrics, IReadOnlyDictionary<string, long> Samples, double Coverage);
|
||||
|
||||
public sealed record EdgePartialDto(bool First, bool Last);
|
||||
|
||||
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
||||
|
||||
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
||||
|
||||
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
||||
|
||||
public sealed record OverviewLineDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string ProbeName,
|
||||
string Name,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
int EndpointChanges,
|
||||
SummaryDto Summary,
|
||||
TestSummaryDto? LastTest);
|
||||
|
||||
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
|
||||
|
||||
/// <summary>列式响应:时间轴 <c>t</c>(Unix 毫秒)只出现一次,<c>values</c> 里每个数组的长度都与 <c>t</c> 相同;没有数据的桶是 null。</summary>
|
||||
public sealed record SeriesDto(
|
||||
string LineId,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
DateTimeOffset From,
|
||||
DateTimeOffset To,
|
||||
string Step,
|
||||
string Source,
|
||||
bool Live,
|
||||
bool Degraded,
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyDictionary<string, double?[]> Values,
|
||||
SummaryDto Summary,
|
||||
EdgePartialDto EdgePartial,
|
||||
IReadOnlyList<GapDto> Gaps,
|
||||
IReadOnlyList<EventDto> Events,
|
||||
IReadOnlyDictionary<string, string> Versions);
|
||||
|
||||
public sealed record CompareDto(
|
||||
string Metric,
|
||||
DateTimeOffset From,
|
||||
DateTimeOffset To,
|
||||
string Step,
|
||||
string Source,
|
||||
bool Degraded,
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyDictionary<string, double?[]> Values,
|
||||
IReadOnlyDictionary<string, double?> Summary);
|
||||
|
||||
public sealed record DistributionBucketDto(double Le, long Count);
|
||||
|
||||
public sealed record DistributionDto(string Metric, string Unit, IReadOnlyList<DistributionBucketDto> Buckets, double? P50, double? P95, double? P99, long Count);
|
||||
|
||||
public sealed record HeatmapDto(
|
||||
string Metric,
|
||||
string Step,
|
||||
string Source,
|
||||
bool Degraded,
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyList<double> Edges,
|
||||
IReadOnlyList<IReadOnlyList<long>> Counts);
|
||||
|
||||
/// <summary>一条线路 = 一对(Agent,Probe);<c>id</c> 是 <c><agent_id>~<probe_id></c>。</summary>
|
||||
public sealed record LineDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string ProbeName,
|
||||
string Name,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
DateTimeOffset? DataSince,
|
||||
IReadOnlyList<string> Kinds,
|
||||
TestSummaryDto? LastTest);
|
||||
|
||||
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
|
||||
|
||||
public sealed record ScoreDto(double? Score, int Version, bool Estimated, IReadOnlyList<DimensionDto> Dimensions, IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestStepDto(int Index, double TargetMbps, double LossPct, double? JitterP95Ms, double DeliveredMbps);
|
||||
|
||||
public sealed record TestResultDto(
|
||||
IReadOnlyDictionary<string, double> Metrics,
|
||||
IReadOnlyDictionary<string, ScoreDto> Scores,
|
||||
IReadOnlyDictionary<string, double?[]> Series,
|
||||
IReadOnlyList<TestStepDto> Steps,
|
||||
IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string Plan,
|
||||
IReadOnlyList<string> Phases,
|
||||
string Status,
|
||||
int? Phase,
|
||||
string? ErrorCode,
|
||||
string Trigger,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
TestResultDto? Result);
|
||||
|
||||
// ── 目录(由代码注册表生成) ──────────────────────────────────────────
|
||||
|
||||
public sealed record ParamDto(string Name, string Type, System.Text.Json.JsonElement Default, double? Min, double? Max, string? Unit, IReadOnlyList<string>? Values, string Description);
|
||||
|
||||
public sealed record BlocksDto(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList<string> Samplers);
|
||||
|
||||
public sealed record ColumnDto(string Name, string Type, string Encoding, bool Nullable, string? Role);
|
||||
|
||||
public sealed record StreamDto(string Name, IReadOnlyList<ColumnDto> Columns);
|
||||
|
||||
public sealed record KindDto(string Id, string Title, string Description, IReadOnlyList<string> Scopes, BlocksDto Blocks, IReadOnlyList<ParamDto> Params, IReadOnlyList<StreamDto> Streams);
|
||||
|
||||
public sealed record PhaseDto(string Kind, int DurationS, System.Text.Json.JsonElement Params);
|
||||
|
||||
public sealed record PlanDto(string Id, string Title, string Description, IReadOnlyList<PhaseDto> Phases);
|
||||
|
||||
public sealed record MetricDto(
|
||||
string Id,
|
||||
string DefinitionId,
|
||||
string Accumulator,
|
||||
string Stat,
|
||||
string Label,
|
||||
string Description,
|
||||
string Unit,
|
||||
string Better,
|
||||
double? Good,
|
||||
double? Poor,
|
||||
string Version,
|
||||
IReadOnlyList<string> From);
|
||||
|
||||
public sealed record PresetDto(string Id, string Label, IReadOnlyList<string> Metrics);
|
||||
|
||||
public sealed record ModelDto(string Id, string Title, int Version, IReadOnlyList<string> Needs);
|
||||
|
||||
public sealed record LiveNodeDto(string Kind, string Id, bool Online, DateTimeOffset At);
|
||||
|
||||
public sealed record LiveTestDto(string TestId, string Status, int? Phase, int Phases);
|
||||
|
||||
/// <summary>
|
||||
/// 主机资源占用(展示页能拿到的全部内容):只有汇总的数字,<b>没有 CPU 型号、主机名、网卡名,也没有任何 IP 地址</b>。
|
||||
/// 这些字段 Agent 根本不采集,所以即使这里写漏了也泄漏不出去。
|
||||
/// </summary>
|
||||
public sealed record HostDto(
|
||||
double? CpuPercent,
|
||||
long? MemUsed,
|
||||
long? MemTotal,
|
||||
long? DiskUsed,
|
||||
long? DiskTotal,
|
||||
long? NetRxTotal,
|
||||
long? NetTxTotal,
|
||||
long? NetRxBps,
|
||||
long? NetTxBps,
|
||||
long? UptimeSeconds,
|
||||
DateTimeOffset At);
|
||||
|
||||
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
|
||||
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
|
||||
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
|
||||
|
||||
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
|
||||
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<ProjectReference Include="..\Monitor.Backend.Application\Monitor.Backend.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
|
||||
public static class OpsEndpoints
|
||||
{
|
||||
private const string Tag = "ops";
|
||||
|
||||
public static void Map(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/healthz", HealthAsync)
|
||||
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
||||
.Returns<HealthDto>().ProducesProblems(503);
|
||||
app.MapGet("/metrics", MetricsAsync)
|
||||
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||
app.MapGet("/install.sh", (HttpContext http) => NotShipped(http))
|
||||
.Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501);
|
||||
app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http))
|
||||
.Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||
{
|
||||
StorageHealth health = await ops.HealthAsync(http.RequestAborted);
|
||||
bool ok = health.Database && health.RawDirectory;
|
||||
HealthDto dto = new(ok ? "ok" : "unavailable", health.Database, health.RawDirectory);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(dto, ApiJsonContext.Default.HealthDto, statusCode: ok ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
{
|
||||
return principal.CanRead
|
||||
? Problems.Create(http, MonitorErrors.Forbidden, "an admin token is required")
|
||||
: Problems.Create(http, MonitorErrors.Unauthorized, "an admin token is required");
|
||||
}
|
||||
|
||||
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
||||
}
|
||||
|
||||
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
|
||||
private static IResult NotShipped(HttpContext http) =>
|
||||
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>目录:由代码里的注册表生成。新增 Kind、方案、指标、分组、模型后,目录自动更新,前端不需要改。</summary>
|
||||
public static class CatalogEndpoints
|
||||
{
|
||||
private const string Tag = "catalog";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
RouteGroupBuilder catalog = group.MapGroup("/catalog");
|
||||
catalog.MapGet("/kinds", Kinds)
|
||||
.Api("catalog_kinds", "Measurement kinds with their building blocks and parameter specs", Tag).Returns<PageDto<KindDto>>().ProducesProblems(403);
|
||||
catalog.MapGet("/plans", Plans)
|
||||
.Api("catalog_plans", "On-demand test plans", Tag).Returns<PageDto<PlanDto>>().ProducesProblems(403);
|
||||
catalog.MapGet("/metrics", Metrics)
|
||||
.Api("catalog_metrics", "Metric ids with unit, label, direction, thresholds and version", Tag).Returns<PageDto<MetricDto>>().ProducesProblems(403);
|
||||
catalog.MapGet("/presets", Presets)
|
||||
.Api("catalog_presets", "Metric groups the front end can display", Tag).Returns<PageDto<PresetDto>>().ProducesProblems(403);
|
||||
catalog.MapGet("/models", Models)
|
||||
.Api("catalog_models", "Service models and the metrics they need", Tag).Returns<PageDto<ModelDto>>().ProducesProblems(403);
|
||||
}
|
||||
|
||||
private static IResult Kinds(HttpContext http) =>
|
||||
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', Monitor.Protocol.Kinds.All.Select(k => k.Id)))
|
||||
?? TypedResults.Json(new PageDto<KindDto>([.. Monitor.Protocol.Kinds.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoKindDto);
|
||||
|
||||
private static IResult Plans(HttpContext http) =>
|
||||
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', Monitor.Backend.Application.Plans.All.Select(p => p.Id)))
|
||||
?? TypedResults.Json(new PageDto<PlanDto>([.. Monitor.Backend.Application.Plans.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPlanDto);
|
||||
|
||||
private static IResult Metrics(HttpContext http) =>
|
||||
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.All.Select(m => m.Fingerprint)))
|
||||
?? TypedResults.Json(
|
||||
new PageDto<MetricDto>([.. MetricRegistry.AllExports().Select(e => Mappers.ToDto(e.Def, e.Export))], null),
|
||||
ApiJsonContext.Default.PageDtoMetricDto);
|
||||
|
||||
private static IResult Presets(HttpContext http) =>
|
||||
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.Presets.Select(p => p.Id + string.Join('|', p.MetricIds))))
|
||||
?? TypedResults.Json(
|
||||
new PageDto<PresetDto>([.. MetricRegistry.Presets.Select(p => new PresetDto(p.Id, p.Label, p.MetricIds))], null),
|
||||
ApiJsonContext.Default.PageDtoPresetDto);
|
||||
|
||||
private static IResult Models(HttpContext http) =>
|
||||
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', ModelRegistry.All.Select(m => m.Id + m.Version)))
|
||||
?? TypedResults.Json(new PageDto<ModelDto>([.. ModelRegistry.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoModelDto);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>Agent 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
|
||||
public static class HostEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/hosts", ListAsync)
|
||||
.Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
|
||||
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
|
||||
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
|
||||
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
|
||||
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
|
||||
{
|
||||
var items = new List<HostItemDto>();
|
||||
foreach (AgentView view in await agents.ListAsync(ct))
|
||||
{
|
||||
if (view.Agent.Status != Fleet.Server.NodeStatus.Active || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
|
||||
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
|
||||
}
|
||||
|
||||
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
|
||||
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
|
||||
{
|
||||
return Problems.From(http, new FleetError(MonitorErrors.InvalidRange, "range must look like 1h, 6h, 24h or 7d (between 1 hour and 30 days)"));
|
||||
}
|
||||
|
||||
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long from = now - span;
|
||||
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
|
||||
|
||||
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
|
||||
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
|
||||
long first = from - (from % stepMs);
|
||||
int points = (int)(((now - first) / stepMs) + 1);
|
||||
var t = new long[points];
|
||||
for (int i = 0; i < points; i++)
|
||||
{
|
||||
t[i] = first + (i * stepMs);
|
||||
}
|
||||
|
||||
double?[] Average(Func<HostSample, double?> pick)
|
||||
{
|
||||
var sum = new double[points];
|
||||
var count = new int[points];
|
||||
foreach (HostSample s in samples)
|
||||
{
|
||||
int index = (int)((s.AtMs - first) / stepMs);
|
||||
if (index >= 0 && index < points && pick(s) is { } v)
|
||||
{
|
||||
sum[index] += v;
|
||||
count[index]++;
|
||||
}
|
||||
}
|
||||
|
||||
return [.. Enumerable.Range(0, points).Select(i => count[i] == 0 ? (double?)null : Math.Round(sum[i] / count[i], 2))];
|
||||
}
|
||||
|
||||
static double? Percent(long? used, long? total) => used is { } u && total is { } tot && tot > 0 ? 100.0 * u / tot : null;
|
||||
var values = new Dictionary<string, double?[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["cpu"] = Average(s => s.Cpu),
|
||||
["mem"] = Average(s => Percent(s.MemUsed, s.MemTotal)),
|
||||
["disk"] = Average(s => Percent(s.DiskUsed, s.DiskTotal)),
|
||||
["rx_bps"] = Average(s => s.NetRxBps),
|
||||
["tx_bps"] = Average(s => s.NetTxBps),
|
||||
};
|
||||
return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 实时推送(SSE):先推送当前快照,再推送增量(不补发历史)。SSE 只负责“更快地获得变化”,
|
||||
/// 推送的内容都能由对应的 REST 查询重建。实时延迟取决于原始块的切块节奏(10 s)加上处理时间,典型值 10~12 s。
|
||||
/// </summary>
|
||||
public static class LiveEndpoints
|
||||
{
|
||||
private static readonly TimeSpan s_tick = TimeSpan.FromMilliseconds(500);
|
||||
private static readonly TimeSpan s_debounce = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan s_heartbeat = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan s_periodicRefresh = TimeSpan.FromSeconds(10);
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/live", StreamAsync)
|
||||
.Api("live_stream", "Server-sent events: nodes, overview, series and test topics", "public")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||
.ProducesProblems(400, 403, 404, 422, 429);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StreamAsync(
|
||||
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime)
|
||||
{
|
||||
StringValues raw = http.Request.Query["topics"];
|
||||
Result<IReadOnlyList<LiveTopic>> parsed = LiveTopic.ParseAll(raw.Where(v => v is not null)!, options.MaxSseTopics);
|
||||
if (!parsed.TryGet(out IReadOnlyList<LiveTopic>? topics, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
// 订阅的线路必须存在并且对访问者可见
|
||||
var lineKeys = new Dictionary<string, LineKey>(StringComparer.Ordinal);
|
||||
foreach (LiveTopic topic in topics.Where(t => t.Line is not null))
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lineService, topic.Line!, http.RequestAborted);
|
||||
if (!found.TryGet(out LineView? resolved, out FleetError? lineError))
|
||||
{
|
||||
return Problems.From(http, lineError);
|
||||
}
|
||||
|
||||
lineKeys[topic.Raw] = resolved.Key;
|
||||
}
|
||||
|
||||
foreach (LiveTopic topic in topics.Where(t => t.Preset is not null))
|
||||
{
|
||||
Result<MetricSelection> selection = MetricSelection.Resolve(null, topic.Preset, options.MaxMetricsPerQuery);
|
||||
if (!selection.IsSuccess)
|
||||
{
|
||||
return Problems.From(http, selection.Error);
|
||||
}
|
||||
}
|
||||
|
||||
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
if (!limiter.TryAcquire(client))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
||||
}
|
||||
|
||||
counters.SseOpened();
|
||||
try
|
||||
{
|
||||
http.Response.ContentType = "text/event-stream";
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await http.Response.Body.FlushAsync(http.RequestAborted);
|
||||
|
||||
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
|
||||
await session.RunAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 客户端断开或服务正在停止
|
||||
}
|
||||
finally
|
||||
{
|
||||
counters.SseClosed();
|
||||
limiter.Release(client);
|
||||
}
|
||||
|
||||
return TypedResults.Empty;
|
||||
}
|
||||
|
||||
private sealed class LiveSession
|
||||
{
|
||||
private readonly HttpContext _http;
|
||||
private readonly AgentService _agents;
|
||||
private readonly LineService _lines;
|
||||
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
||||
private readonly TestService _tests;
|
||||
private readonly QueryService _query;
|
||||
private readonly LiveHub _hub;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly IReadOnlyList<LiveTopic> _topics;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, long> _lastSent = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<(NodeId Agent, string Series)> _dirtyLines = [];
|
||||
private readonly HashSet<TestId> _dirtyTests = [];
|
||||
private readonly Queue<NodeSignal> _nodeSignals = new();
|
||||
private readonly Dictionary<NodeId, HostSignal> _hostSignals = [];
|
||||
private long _pushAtMs;
|
||||
private long _lastWriteMs;
|
||||
private long _lastPeriodicMs;
|
||||
|
||||
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys)
|
||||
{
|
||||
_http = http;
|
||||
_agents = agents;
|
||||
_lines = lines;
|
||||
_lineKeys = lineKeys;
|
||||
_tests = tests;
|
||||
_query = query;
|
||||
_hub = hub;
|
||||
_options = options;
|
||||
_time = time;
|
||||
_topics = topics;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using LiveSubscription subscription = _hub.Subscribe();
|
||||
Task drain = DrainAsync(subscription, ct);
|
||||
long now = Now();
|
||||
_lastWriteMs = now;
|
||||
_lastPeriodicMs = now;
|
||||
await PushSnapshotAsync(ct);
|
||||
|
||||
using var timer = new PeriodicTimer(s_tick, _time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
await FlushPendingAsync(ct);
|
||||
}
|
||||
|
||||
await drain;
|
||||
}
|
||||
|
||||
private async Task DrainAsync(LiveSubscription subscription, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (LiveSignal signal in subscription.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
switch (signal)
|
||||
{
|
||||
case NodeSignal node when _topics.Any(t => t.Kind == LiveTopicKind.Nodes):
|
||||
_nodeSignals.Enqueue(node);
|
||||
break;
|
||||
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
|
||||
_hostSignals[host.AgentId] = host;
|
||||
break;
|
||||
case ChunkSignal chunk:
|
||||
_dirtyLines.Add((chunk.AgentId, chunk.Series));
|
||||
_pushAtMs = _pushAtMs == 0 ? Now() + (long)s_debounce.TotalMilliseconds : _pushAtMs;
|
||||
break;
|
||||
case TestSignal test when _topics.Any(t => t.Test == test.TestId):
|
||||
_dirtyTests.Add(test.TestId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 连接结束
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushPendingAsync(CancellationToken ct)
|
||||
{
|
||||
long now = Now();
|
||||
NodeSignal[] nodes;
|
||||
HostSignal[] hostSignals;
|
||||
TestId[] testIds;
|
||||
(NodeId Agent, string Series)[] dirtyLines = [];
|
||||
bool refreshAll = now - _lastPeriodicMs >= (long)s_periodicRefresh.TotalMilliseconds;
|
||||
lock (_gate)
|
||||
{
|
||||
nodes = [.. _nodeSignals];
|
||||
_nodeSignals.Clear();
|
||||
hostSignals = [.. _hostSignals.Values];
|
||||
_hostSignals.Clear();
|
||||
testIds = [.. _dirtyTests];
|
||||
_dirtyTests.Clear();
|
||||
if (_pushAtMs != 0 && now >= _pushAtMs)
|
||||
{
|
||||
dirtyLines = [.. _dirtyLines];
|
||||
_dirtyLines.Clear();
|
||||
_pushAtMs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (HostSignal host in hostSignals)
|
||||
{
|
||||
AgentView? view = await _agents.GetAsync(host.AgentId, ct);
|
||||
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (NodeSignal node in nodes)
|
||||
{
|
||||
AgentView? view = await _agents.GetAsync(new NodeId(node.Id), ct);
|
||||
if (view is not null && IsVisible(view))
|
||||
{
|
||||
await WriteAsync("node", new LiveNodeDto(node.Kind, node.Id, node.Online, Mappers.Time(node.AtMs)), ApiJsonContext.Default.LiveNodeDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TestId testId in testIds)
|
||||
{
|
||||
await PushTestAsync(testId, ct);
|
||||
}
|
||||
|
||||
if (dirtyLines.Length > 0)
|
||||
{
|
||||
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Agent, _lineKeys[t.Raw].Series))))
|
||||
{
|
||||
await PushSeriesAsync(topic, ct);
|
||||
}
|
||||
|
||||
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Overview))
|
||||
{
|
||||
await PushOverviewAsync(topic, ct);
|
||||
}
|
||||
|
||||
_lastPeriodicMs = now;
|
||||
}
|
||||
else if (refreshAll)
|
||||
{
|
||||
// 没有新块也定期刷新(例如线路断了之后汇总需要更新)
|
||||
foreach (LiveTopic topic in _topics.Where(t => t.Kind is LiveTopicKind.Overview or LiveTopicKind.Series))
|
||||
{
|
||||
await (topic.Kind == LiveTopicKind.Overview ? PushOverviewAsync(topic, ct) : PushSeriesAsync(topic, ct));
|
||||
}
|
||||
|
||||
_lastPeriodicMs = now;
|
||||
}
|
||||
|
||||
if (Now() - _lastWriteMs >= (long)s_heartbeat.TotalMilliseconds)
|
||||
{
|
||||
await WriteRawAsync(": keepalive\n\n", ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushSnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (LiveTopic topic in _topics)
|
||||
{
|
||||
switch (topic.Kind)
|
||||
{
|
||||
case LiveTopicKind.Nodes:
|
||||
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v)))
|
||||
{
|
||||
await WriteAsync("node", new LiveNodeDto("agent", view.Agent.Id.Value, view.Online, Mappers.Time(Now())), ApiJsonContext.Default.LiveNodeDto, ct);
|
||||
}
|
||||
|
||||
break;
|
||||
case LiveTopicKind.Hosts:
|
||||
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
break;
|
||||
case LiveTopicKind.Overview:
|
||||
await PushOverviewAsync(topic, ct);
|
||||
break;
|
||||
case LiveTopicKind.Series:
|
||||
await PushSeriesAsync(topic, ct);
|
||||
break;
|
||||
case LiveTopicKind.Test:
|
||||
await PushTestAsync(topic.Test!.Value, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushOverviewAsync(LiveTopic topic, CancellationToken ct)
|
||||
{
|
||||
MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value;
|
||||
IReadOnlyList<LineView> visible = await _lines.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(a), ct);
|
||||
Result<OverviewResult> result = await _query.OverviewAsync(visible, new RangeRequest(topic.Range, null, null, null, null), selection, ct);
|
||||
if (result.TryGet(out OverviewResult? overview, out _))
|
||||
{
|
||||
var dto = new OverviewDto(
|
||||
topic.Range!, Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs),
|
||||
overview.Plan.Name, overview.Plan.Degraded, [.. overview.Lines.Select(Mappers.ToDto)]);
|
||||
await WriteAsync("overview", dto, ApiJsonContext.Default.OverviewDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushSeriesAsync(LiveTopic topic, CancellationToken ct)
|
||||
{
|
||||
MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value;
|
||||
string step = Steps.Format(topic.StepMs);
|
||||
long now = Now();
|
||||
long from = _lastSent.TryGetValue(topic.Raw, out long last) ? last : now - 20_000;
|
||||
|
||||
Result<SeriesResult> latest = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), null, step, null), selection, ct);
|
||||
Result<SeriesResult> summary = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest("15m", null, null, step, null), selection, ct);
|
||||
if (!latest.TryGet(out SeriesResult? series, out _) || !summary.TryGet(out SeriesResult? whole, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (series.T.Count > 0)
|
||||
{
|
||||
_lastSent[topic.Raw] = series.T[^1];
|
||||
}
|
||||
|
||||
SeriesDto dto = Mappers.ToDto(series, live: true) with { Summary = Mappers.ToDto(whole.Summary) };
|
||||
await WriteAsync("series", dto, ApiJsonContext.Default.SeriesDto, ct);
|
||||
}
|
||||
|
||||
private async Task PushTestAsync(TestId testId, CancellationToken ct)
|
||||
{
|
||||
TestDetail? detail = await _tests.GetAsync(testId, ct);
|
||||
if (detail is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AgentView? view = await _agents.GetAsync(detail.Test.AgentId, ct);
|
||||
if (view is null || !IsVisible(view))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteAsync("test", new LiveTestDto(testId.Value, detail.Test.Status, detail.Test.Phase, detail.Test.PhaseCount), ApiJsonContext.Default.LiveTestDto, ct);
|
||||
}
|
||||
|
||||
private bool IsVisible(AgentView view) => view.Agent.PublicVisible || PublicGuard.CanSeeHidden(_http);
|
||||
|
||||
private async Task WriteAsync<T>(string name, T payload, JsonTypeInfo<T> typeInfo, CancellationToken ct)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(payload, typeInfo);
|
||||
await WriteRawAsync($"event: {name}\ndata: {json}\n\n", ct);
|
||||
}
|
||||
|
||||
private async Task WriteRawAsync(string text, CancellationToken ct)
|
||||
{
|
||||
await _http.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(text), ct);
|
||||
await _http.Response.Body.FlushAsync(ct);
|
||||
_lastWriteMs = Now();
|
||||
}
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
internal enum LiveTopicKind
|
||||
{
|
||||
Nodes,
|
||||
Hosts,
|
||||
Overview,
|
||||
Series,
|
||||
Test,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅主题:<c>nodes</c>、<c>hosts</c>(Agent 主机资源占用的实时推送)、<c>overview:{range}:{preset}</c>、<c>series:{line_id}:{step}:{preset}</c>(步长 ≤ 1m)、<c>test:{test_id}</c>。
|
||||
/// preset 可以是逗号分隔的多个分组,所以不含冒号的片段会并入前一个主题的 preset。
|
||||
/// </summary>
|
||||
internal sealed record LiveTopic(LiveTopicKind Kind, string Raw, string? Range, long StepMs, string? Preset, string? Line, TestId? Test)
|
||||
{
|
||||
public static Result<IReadOnlyList<LiveTopic>> ParseAll(IEnumerable<string> values, int maxTopics)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
foreach (string value in values)
|
||||
{
|
||||
foreach (string token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!token.Contains(':', StringComparison.Ordinal) && token is not ("nodes" or "hosts") && tokens.Count > 0
|
||||
&& (tokens[^1].StartsWith("overview:", StringComparison.Ordinal) || tokens[^1].StartsWith("series:", StringComparison.Ordinal)))
|
||||
{
|
||||
tokens[^1] += "," + token;
|
||||
}
|
||||
else
|
||||
{
|
||||
tokens.Add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return ValidationError.Fail<IReadOnlyList<LiveTopic>>("topics", "at least one topic is required");
|
||||
}
|
||||
|
||||
if (tokens.Count > maxTopics)
|
||||
{
|
||||
return Result<IReadOnlyList<LiveTopic>>.Fail(MonitorErrors.TooManyStreams, $"at most {maxTopics} topics per connection");
|
||||
}
|
||||
|
||||
var topics = new List<LiveTopic>();
|
||||
foreach (string token in tokens.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
Result<LiveTopic> parsed = Parse(token);
|
||||
if (!parsed.TryGet(out LiveTopic? topic, out FleetError? error))
|
||||
{
|
||||
return Result<IReadOnlyList<LiveTopic>>.Fail(error);
|
||||
}
|
||||
|
||||
topics.Add(topic);
|
||||
}
|
||||
|
||||
return Result<IReadOnlyList<LiveTopic>>.Ok(topics);
|
||||
}
|
||||
|
||||
private static Result<LiveTopic> Parse(string token)
|
||||
{
|
||||
string[] parts = token.Split(':', 4);
|
||||
switch (parts[0])
|
||||
{
|
||||
case "hosts" when parts.Length == 1:
|
||||
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Hosts, token, null, 0, null, null, null));
|
||||
case "nodes" when parts.Length == 1:
|
||||
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Nodes, token, null, 0, null, null, null));
|
||||
case "overview" when parts.Length == 3:
|
||||
if (!RangeResolver.TryParseSpan(parts[1], out _))
|
||||
{
|
||||
return ValidationError.Fail<LiveTopic>("topics", $"{token}: range must look like 5m, 6h or 7d");
|
||||
}
|
||||
|
||||
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Overview, token, parts[1], 0, parts[2], null, null));
|
||||
case "series" when parts.Length == 4:
|
||||
if (!Steps.TryParse(parts[2], out long step) || step > Steps.Minute)
|
||||
{
|
||||
return Result<LiveTopic>.Fail(MonitorErrors.InvalidStep, $"{token}: series topics support steps up to 1m; refetch coarser steps over REST");
|
||||
}
|
||||
|
||||
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Series, token, null, step, parts[3], parts[1], null));
|
||||
case "test" when parts.Length == 2:
|
||||
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Test, token, null, 0, null, null, new TestId(parts[1])));
|
||||
default:
|
||||
return ValidationError.Fail<LiveTopic>("topics", $"unknown topic {token}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每个客户端最多同时 4 个 SSE 连接。</summary>
|
||||
public sealed class SseLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, int> _open = new();
|
||||
private readonly BackendOptions _options;
|
||||
|
||||
public SseLimiter(BackendOptions options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public bool TryAcquire(string client)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int current = _open.GetOrAdd(client, 0);
|
||||
if (current >= _options.MaxSsePerClient)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_open.TryUpdate(client, current + 1, current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Release(string client)
|
||||
{
|
||||
while (_open.TryGetValue(client, out int current))
|
||||
{
|
||||
if (current <= 1 ? _open.TryRemove(new KeyValuePair<string, int>(client, current)) : _open.TryUpdate(client, current - 1, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>展示数据的查询端点:参数解析、校验与缓存头,业务都在 Application.Query。</summary>
|
||||
public static class QueryEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
private const string DefaultOverviewPreset = "udp,tcp,scores";
|
||||
private const string DefaultSeriesPreset = "udp,tcp";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/overview", OverviewAsync)
|
||||
.Api("overview_get", "Ranking of all lines over a time range", Tag)
|
||||
.Returns<OverviewDto>().ProducesProblems(403, 422, 429, 503);
|
||||
group.MapGet("/lines/{line_id}", LineAsync)
|
||||
.Api("lines_get", "One line", Tag)
|
||||
.Returns<LineDto>().ProducesProblems(403, 404);
|
||||
group.MapGet("/lines/{line_id}/series", SeriesAsync)
|
||||
.Api("lines_series", "Time series of one line (columnar)", Tag)
|
||||
.Returns<SeriesDto>().ProducesProblems(403, 404, 422, 429, 503);
|
||||
group.MapGet("/compare", CompareAsync)
|
||||
.Api("lines_compare", "One metric across several lines, aligned on one time axis", Tag)
|
||||
.Returns<CompareDto>().ProducesProblems(403, 404, 422, 429, 503);
|
||||
group.MapGet("/lines/{line_id}/distribution", DistributionAsync)
|
||||
.Api("lines_distribution", "Histogram and CDF of a histogram metric", Tag)
|
||||
.Returns<DistributionDto>().ProducesProblems(403, 404, 422, 429, 503);
|
||||
group.MapGet("/lines/{line_id}/heatmap", HeatmapAsync)
|
||||
.Api("lines_heatmap", "Time by latency heat map", Tag)
|
||||
.Returns<HeatmapDto>().ProducesProblems(403, 404, 422, 429, 503);
|
||||
group.MapGet("/tests/{test_id}", TestAsync)
|
||||
.Api("tests_get", "A test with its result", Tag)
|
||||
.Returns<TestDto>().ProducesProblems(403, 404);
|
||||
}
|
||||
|
||||
private static Task<IResult> OverviewAsync(
|
||||
HttpContext http, LineService lineService, QueryService query, BackendOptions options, TimeProvider time,
|
||||
string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery);
|
||||
if (!selection.TryGet(out MetricSelection? sel, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
IReadOnlyList<LineView> visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct);
|
||||
Result<OverviewResult> result = await query.OverviewAsync(visible, new RangeRequest(range, from, to, null, null), sel, ct);
|
||||
if (!result.TryGet(out OverviewResult? overview, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
IEnumerable<OverviewLine> lines = Sort(overview.Lines, sort, order, sel);
|
||||
IResult? cached = QueryHelpers.ApplyCaching(http, overview.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), overview.Plan.Name + string.Join(',', sel.AllIds));
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var dto = new OverviewDto(
|
||||
range ?? "custom", Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs),
|
||||
overview.Plan.Name, overview.Plan.Degraded, [.. lines.Select(Mappers.ToDto)]);
|
||||
return TypedResults.Json(dto, ApiJsonContext.Default.OverviewDto);
|
||||
});
|
||||
|
||||
private static async Task<IResult> LineAsync(HttpContext http, LineService lines, TestService tests, QueryService query, string line_id, CancellationToken ct)
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
||||
if (!found.TryGet(out LineView? line, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
AgentView view = line.Agent;
|
||||
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, ct);
|
||||
TestRecord? last = recent.Count > 0 ? recent[0] : null;
|
||||
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)),
|
||||
ApiJsonContext.Default.LineDto);
|
||||
}
|
||||
|
||||
private static Task<IResult> SeriesAsync(
|
||||
HttpContext http, LineService lines, QueryService query, BackendOptions options, TimeProvider time,
|
||||
string line_id, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step, string? tz, string? preset, string? metrics, string? anchor) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
||||
if (!found.TryGet(out LineView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultSeriesPreset : null), options.MaxMetricsPerQuery);
|
||||
if (!selection.TryGet(out MetricSelection? sel, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Result<SeriesResult> result = await query.SeriesAsync(view.Key, new RangeRequest(range, from, to, step, tz, anchor), sel, ct);
|
||||
if (!result.TryGet(out SeriesResult? series, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
IResult? cached = QueryHelpers.ApplyCaching(http, series.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), series.Plan.Name + string.Join(',', series.Versions.Values));
|
||||
return cached ?? TypedResults.Json(Mappers.ToDto(series, live: true), ApiJsonContext.Default.SeriesDto);
|
||||
});
|
||||
|
||||
private static Task<IResult> CompareAsync(
|
||||
HttpContext http, LineService lineService, QueryService query, TimeProvider time,
|
||||
[FromQuery(Name = "lines")] string? lineIds, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
string? list = lineIds;
|
||||
if (string.IsNullOrWhiteSpace(list))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("lines", "is required"));
|
||||
}
|
||||
|
||||
var ids = new List<LineKey>();
|
||||
foreach (string id in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lineService, id, ct);
|
||||
if (!found.TryGet(out LineView? view, out FleetError? findError))
|
||||
{
|
||||
return Problems.From(http, findError);
|
||||
}
|
||||
|
||||
ids.Add(view.Key);
|
||||
}
|
||||
|
||||
Result<CompareResult> result = await query.CompareAsync(ids, metric, new RangeRequest(range, from, to, step, null), ct);
|
||||
if (!result.TryGet(out CompareResult? compare, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
IResult? cached = QueryHelpers.ApplyCaching(http, compare.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), compare.Plan.Name + metric);
|
||||
return cached ?? TypedResults.Json(
|
||||
new CompareDto(
|
||||
compare.Metric, Mappers.Time(compare.Range.FromMs), Mappers.Time(compare.Range.ToMs), Steps.Format(compare.Range.StepMs), compare.Plan.Name, compare.Plan.Degraded,
|
||||
compare.T, compare.Values, compare.Summary),
|
||||
ApiJsonContext.Default.CompareDto);
|
||||
});
|
||||
|
||||
private static Task<IResult> DistributionAsync(
|
||||
HttpContext http, LineService lines, QueryService query, TimeProvider time,
|
||||
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
||||
if (!found.TryGet(out LineView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
var request = new RangeRequest(range, from, to, null, null);
|
||||
Result<DistributionResult> result = await query.DistributionAsync(view.Key, metric, request, ct);
|
||||
if (!result.TryGet(out DistributionResult? distribution, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = query.Plan(request, "15m");
|
||||
IResult? cached = planned.IsSuccess ? QueryHelpers.ApplyCaching(http, planned.Value.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), metric) : null;
|
||||
return cached ?? TypedResults.Json(
|
||||
new DistributionDto(
|
||||
distribution.Metric, distribution.Unit, [.. distribution.Buckets.Select(b => new DistributionBucketDto(b.Le, checked((long)b.Count)))],
|
||||
distribution.P50, distribution.P95, distribution.P99, checked((long)distribution.Count)),
|
||||
ApiJsonContext.Default.DistributionDto);
|
||||
});
|
||||
|
||||
private static Task<IResult> HeatmapAsync(
|
||||
HttpContext http, LineService lines, QueryService query, TimeProvider time,
|
||||
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
|
||||
if (!found.TryGet(out LineView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Result<HeatmapResult> result = await query.HeatmapAsync(view.Key, metric, new RangeRequest(range, from, to, step, null), ct);
|
||||
if (!result.TryGet(out HeatmapResult? heatmap, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
IResult? cached = QueryHelpers.ApplyCaching(http, heatmap.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), heatmap.Plan.Name + metric);
|
||||
return cached ?? TypedResults.Json(
|
||||
new HeatmapDto(
|
||||
metric, Steps.Format(heatmap.Range.StepMs), heatmap.Plan.Name, heatmap.Plan.Degraded, heatmap.T, heatmap.Edges,
|
||||
[.. heatmap.Counts.Select(row => (IReadOnlyList<long>)[.. row.Select(c => checked((long)c))])]),
|
||||
ApiJsonContext.Default.HeatmapDto);
|
||||
});
|
||||
|
||||
private static async Task<IResult> TestAsync(HttpContext http, TestService tests, AgentService agents, string test_id, CancellationToken ct)
|
||||
{
|
||||
TestDetail? detail = await tests.GetAsync(new TestId(test_id), ct);
|
||||
if (detail is null)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.TestNotFound, "test not found");
|
||||
}
|
||||
|
||||
Result<AgentView> visible = await QueryHelpers.FindVisibleAgentAsync(http, agents, detail.Test.AgentId.Value, ct);
|
||||
if (!visible.IsSuccess)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.TestNotFound, "test not found");
|
||||
}
|
||||
|
||||
return TypedResults.Json(Mappers.ToDto(detail), ApiJsonContext.Default.TestDto);
|
||||
}
|
||||
|
||||
private static IEnumerable<OverviewLine> Sort(IReadOnlyList<OverviewLine> lines, string? sort, string? order, MetricSelection selection)
|
||||
{
|
||||
string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault();
|
||||
if (key is null)
|
||||
{
|
||||
return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
bool descending = order is not null
|
||||
? order.Equals("desc", StringComparison.OrdinalIgnoreCase)
|
||||
: key.StartsWith("score.", StringComparison.Ordinal) || (MetricRegistry.TryFindExport(key, out MetricDef def, out _) && def.Better == Better.Higher);
|
||||
IEnumerable<OverviewLine> withValue = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is not null);
|
||||
IEnumerable<OverviewLine> without = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is null);
|
||||
IOrderedEnumerable<OverviewLine> ordered = descending
|
||||
? withValue.OrderByDescending(l => l.Summary.Values[key])
|
||||
: withValue.OrderBy(l => l.Summary.Values[key]);
|
||||
return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed class EventService
|
||||
{
|
||||
private readonly IEventStore _events;
|
||||
|
||||
public EventService(IEventStore events)
|
||||
{
|
||||
_events = events;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<EventRecord>> ListAsync(NodeId? agentId, string? kind, long fromMs, long toMs, long? beforeId, int limit, CancellationToken ct) =>
|
||||
_events.ListAsync(agentId, kind, fromMs, toMs, beforeId, limit, ct);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand);
|
||||
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
{
|
||||
private readonly IJoinTokenStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (request.MaxUses is < 1 or > 1000)
|
||||
{
|
||||
errors["max_uses"] = "must be between 1 and 1000";
|
||||
}
|
||||
|
||||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||
{
|
||||
errors["expires_in_hours"] = "must be between 1 and 8760";
|
||||
}
|
||||
|
||||
if (request.Labels is { } labels && (labels.Count > 16 || labels.Any(l => l.Length is 0 or > 32)))
|
||||
{
|
||||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
||||
}
|
||||
|
||||
if (request.Note is { Length: > 200 })
|
||||
{
|
||||
errors["note"] = "must be at most 200 characters";
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<CreatedJoinToken>(errors);
|
||||
}
|
||||
|
||||
string plaintext = TokenHash.NewToken("jt");
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
JoinTokenInfo info = await _store.CreateAsync(
|
||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove,
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
||||
|
||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>运维:健康检查和 Prometheus 指标(在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数)。</summary>
|
||||
public sealed class OpsService
|
||||
{
|
||||
private readonly IStorageHealth _health;
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly IRawStore _raw;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly BackendCounters _counters;
|
||||
private readonly MetricRecompute _recompute;
|
||||
|
||||
public OpsService(IStorageHealth health, IMetricStore metrics, IRawStore raw, IAgentGateway gateway, BackendCounters counters, MetricRecompute recompute)
|
||||
{
|
||||
_health = health;
|
||||
_metrics = metrics;
|
||||
_raw = raw;
|
||||
_gateway = gateway;
|
||||
_counters = counters;
|
||||
_recompute = recompute;
|
||||
}
|
||||
|
||||
public ValueTask<StorageHealth> HealthAsync(CancellationToken ct) => _health.CheckAsync(ct);
|
||||
|
||||
public RecomputeProgress RecomputeProgress => _recompute.Progress;
|
||||
|
||||
public ValueTask<int> QueueLengthAsync(CancellationToken ct) => _metrics.QueueLengthAsync(ct);
|
||||
|
||||
public async ValueTask<string> RenderPrometheusAsync(CancellationToken ct)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
void Metric(string name, string help, double value, string type = "gauge")
|
||||
{
|
||||
text.Append("# HELP ").Append(name).Append(' ').AppendLine(help);
|
||||
text.Append("# TYPE ").Append(name).Append(' ').AppendLine(type);
|
||||
text.Append(name).Append(' ').AppendLine(value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
RecomputeProgress progress = _recompute.Progress;
|
||||
Metric("monitor_agents_online", "Agents with an open control connection.", _gateway.OnlineAgents.Count);
|
||||
Metric("monitor_chunks_ingested_total", "Raw chunks stored.", _counters.ChunksIngested, "counter");
|
||||
Metric("monitor_chunks_duplicate_total", "Raw chunks received more than once.", _counters.ChunksDuplicate, "counter");
|
||||
Metric("monitor_chunks_discarded_total", "Raw chunks rejected as invalid.", _counters.ChunksDiscarded, "counter");
|
||||
Metric("monitor_ingest_bytes_total", "Bytes of raw chunks stored.", _counters.BytesIngested, "counter");
|
||||
Metric("monitor_windows_analyzed_total", "Minute windows computed.", _counters.WindowsAnalyzed, "counter");
|
||||
Metric("monitor_analysis_queue_length", "Minute windows waiting to be computed.", await _metrics.QueueLengthAsync(ct));
|
||||
Metric("monitor_recompute_windows_done", "Windows recomputed by the current backfill.", progress.WindowsDone);
|
||||
Metric("monitor_recompute_windows_total", "Windows to recompute in the current backfill.", progress.WindowsTotal);
|
||||
Metric("monitor_raw_bytes", "Disk used by raw segment files.", _raw.TotalBytes);
|
||||
Metric("monitor_sse_connections", "Open server-sent event connections.", _counters.SseConnections);
|
||||
return text.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>原始数据的检索与下载(管理员)。块过了保留期返回 raw_expired,从来不存在返回 chunk_not_found。</summary>
|
||||
public sealed class RawBrowserService
|
||||
{
|
||||
private readonly IRawStore _raw;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public RawBrowserService(IRawStore raw, SettingsService settings, TimeProvider time)
|
||||
{
|
||||
_raw = raw;
|
||||
_settings = settings;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<RawChunkMeta>> ListAsync(RawQuery query, CancellationToken ct) => _raw.FindAsync(query, ct);
|
||||
|
||||
public async ValueTask<Result<(RawChunkMeta Meta, byte[] Bytes)>> GetAsync(ChunkId id, CancellationToken ct)
|
||||
{
|
||||
RawChunkMeta? meta = await _raw.GetMetaAsync(id, ct);
|
||||
if (meta is not null)
|
||||
{
|
||||
byte[]? bytes = await _raw.ReadAsync(meta, ct);
|
||||
return bytes is null
|
||||
? Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.RawExpired, "the raw data has expired")
|
||||
: Result<(RawChunkMeta, byte[])>.Ok((meta, bytes));
|
||||
}
|
||||
|
||||
// 索引已经随保留期清理:ID 里的时间戳(UUIDv7)早于保留期就是 expired
|
||||
int days = await _settings.GetIntAsync(SettingsService.TestRawDays, ct);
|
||||
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (days * 86_400_000L);
|
||||
return ChunkTimestampMs(id.Value) is { } created && created < cutoff
|
||||
? Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.RawExpired, "the raw data has expired")
|
||||
: Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.ChunkNotFound, "chunk not found");
|
||||
}
|
||||
|
||||
/// <summary>UUIDv7 的前 48 位是毫秒时间戳。</summary>
|
||||
public static long? ChunkTimestampMs(Guid id)
|
||||
{
|
||||
if (id.Version != 7)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
id.TryWriteBytes(bytes, bigEndian: true, out _);
|
||||
long ms = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
ms = (ms << 8) | bytes[i];
|
||||
}
|
||||
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record SettingSpec(string Key, string Type, string Default, long Min, long Max, string Description);
|
||||
|
||||
public sealed record SettingView(string Key, string Type, string Value, string Default, string Description);
|
||||
|
||||
/// <summary>站点设置与保留期:键是固定的一组,值经过类型和范围校验。</summary>
|
||||
public sealed class SettingsService
|
||||
{
|
||||
public const string SitePublic = "site.public";
|
||||
public const string RawDays = "retention.raw_days";
|
||||
public const string TestRawDays = "retention.test_raw_days";
|
||||
public const string Metric10sHours = "retention.metric_10s_hours";
|
||||
public const string Metric1mDays = "retention.metric_1m_days";
|
||||
public const string Metric5mDays = "retention.metric_5m_days";
|
||||
public const string EventDays = "retention.events_days";
|
||||
public const string HostDays = "retention.host_days";
|
||||
|
||||
private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly ISettingsStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private long _sitePublicAtMs;
|
||||
private bool _sitePublic;
|
||||
|
||||
public SettingsService(ISettingsStore store, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SettingSpec> Specs => Known(_options);
|
||||
|
||||
private static IReadOnlyList<SettingSpec> Known(BackendOptions o) =>
|
||||
[
|
||||
new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."),
|
||||
new(RawDays, "int", o.RawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of continuous tasks."),
|
||||
new(TestRawDays, "int", o.TestRawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of on-demand tests."),
|
||||
new(Metric10sHours, "int", o.Metric10sRetentionHours.ToString(CultureInfo.InvariantCulture), 1, 720, "Hours to keep the 10 s metric layer."),
|
||||
new(Metric1mDays, "int", o.Metric1mRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep the 1 min metric layer."),
|
||||
new(Metric5mDays, "int", o.Metric5mRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 3650, "Days to keep the 5 min metric layer."),
|
||||
new(EventDays, "int", o.EventRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 3650, "Days to keep events."),
|
||||
new(HostDays, "int", o.HostRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep the per-minute host resource samples."),
|
||||
];
|
||||
|
||||
public async ValueTask<IReadOnlyList<SettingView>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<string, string> stored = await _store.GetAllAsync(ct);
|
||||
return [.. Specs.Select(s => new SettingView(s.Key, s.Type, stored.GetValueOrDefault(s.Key) ?? s.Default, s.Default, s.Description))];
|
||||
}
|
||||
|
||||
public async ValueTask<Result<IReadOnlyList<SettingView>>> PatchAsync(IReadOnlyDictionary<string, JsonElement> changes, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
var accepted = new Dictionary<string, string>();
|
||||
foreach ((string key, JsonElement value) in changes)
|
||||
{
|
||||
SettingSpec? spec = Specs.FirstOrDefault(s => s.Key == key);
|
||||
if (spec is null)
|
||||
{
|
||||
errors[key] = "is not a known setting";
|
||||
}
|
||||
else if (spec.Type == "bool")
|
||||
{
|
||||
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
accepted[key] = value.GetBoolean() ? "true" : "false";
|
||||
}
|
||||
else
|
||||
{
|
||||
errors[key] = "must be a boolean";
|
||||
}
|
||||
}
|
||||
else if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long n) && n >= spec.Min && n <= spec.Max)
|
||||
{
|
||||
accepted[key] = n.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors[key] = $"must be an integer between {spec.Min} and {spec.Max}";
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<IReadOnlyList<SettingView>>(errors);
|
||||
}
|
||||
|
||||
foreach ((string key, string value) in accepted)
|
||||
{
|
||||
await _store.SetAsync(key, value, ct);
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _sitePublicAtMs, 0);
|
||||
return Result<IReadOnlyList<SettingView>>.Ok(await ListAsync(ct));
|
||||
}
|
||||
|
||||
public async ValueTask<bool> IsSitePublicAsync(CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (now - Interlocked.Read(ref _sitePublicAtMs) < (long)s_cacheTtl.TotalMilliseconds)
|
||||
{
|
||||
return _sitePublic;
|
||||
}
|
||||
|
||||
_sitePublic = await _store.GetAsync(SitePublic, ct) == "true";
|
||||
Interlocked.Exchange(ref _sitePublicAtMs, now);
|
||||
return _sitePublic;
|
||||
}
|
||||
|
||||
public async ValueTask<int> GetIntAsync(string key, CancellationToken ct)
|
||||
{
|
||||
SettingSpec spec = Specs.First(s => s.Key == key);
|
||||
string? stored = await _store.GetAsync(key, ct);
|
||||
return int.TryParse(stored, NumberStyles.None, CultureInfo.InvariantCulture, out int value) ? value : int.Parse(spec.Default, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary><c>monitor.event</c>:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。</summary>
|
||||
public sealed class AgentEventService
|
||||
{
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeId agent, AgentEventParams e, CancellationToken ct)
|
||||
{
|
||||
if (!AgentEventKinds.All.Contains(e.Kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 节点上报的时间可能有偏差:限制在合理范围内,避免污染事件时间线
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long at = Math.Abs(e.AtUtcMs - now) > 24L * 3600 * 1000 ? now : e.AtUtcMs;
|
||||
|
||||
AgentRuntimeState state = _runtime.Get(agent);
|
||||
switch (e.Kind)
|
||||
{
|
||||
case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
break;
|
||||
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.RemoveProbe(e.Detail);
|
||||
break;
|
||||
case AgentEventKinds.EndpointChanged:
|
||||
state.CountEndpointChange();
|
||||
break;
|
||||
}
|
||||
|
||||
string? detail = e.Detail is { Length: > 512 } ? e.Detail[..512] : e.Detail;
|
||||
await _events.AddAsync(agent, e.Kind, at, detail, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
|
||||
|
||||
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
|
||||
public sealed class AgentRuntimeState
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private int _endpointChanges;
|
||||
private IReadOnlyList<ConnectedProbeInfo> _probes = [];
|
||||
|
||||
/// <summary>当前连着这个 Agent 的 Probe(每个 Probe 一项)。</summary>
|
||||
public IReadOnlyList<ConnectedProbeInfo> Probes => Volatile.Read(ref _probes);
|
||||
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
|
||||
public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId);
|
||||
|
||||
/// <summary>事件 <c>probe_connected</c>:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。</summary>
|
||||
public void AddProbe(ConnectedProbeInfo info)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id), info]);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveProbe(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>心跳里的完整快照:覆盖事件推算出的结果。</summary>
|
||||
public void ReplaceProbes(IEnumerable<ConnectedProbeInfo> probes)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. probes.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearProbes() => ReplaceProbes([]);
|
||||
|
||||
/// <summary>最新的主机资源占用快照,以及收到它的时间。</summary>
|
||||
public HostSnapshot? Host { get; set; }
|
||||
|
||||
public long HostReceivedAtMs { get; set; }
|
||||
|
||||
public AgentHeartbeatExtra? LastHeartbeat { get; set; }
|
||||
|
||||
public long LastHeartbeatAtMs { get; set; }
|
||||
|
||||
public int EndpointChanges => Volatile.Read(ref _endpointChanges);
|
||||
|
||||
public void CountEndpointChange() => Interlocked.Increment(ref _endpointChanges);
|
||||
}
|
||||
|
||||
public sealed class AgentRuntime
|
||||
{
|
||||
private readonly ConcurrentDictionary<NodeId, AgentRuntimeState> _states = new();
|
||||
|
||||
public AgentRuntimeState Get(NodeId agent) => _states.GetOrAdd(agent, _ => new AgentRuntimeState());
|
||||
|
||||
public bool TryGet(NodeId agent, out AgentRuntimeState state) => _states.TryGetValue(agent, out state!);
|
||||
|
||||
public void Remove(NodeId agent) => _states.TryRemove(agent, out _);
|
||||
|
||||
/// <summary>某个 Probe 现在连着哪些 Agent。</summary>
|
||||
public IReadOnlyList<(NodeId Agent, ConnectedProbeInfo Probe)> ConnectionsOf(string probeId)
|
||||
{
|
||||
var result = new List<(NodeId, ConnectedProbeInfo)>();
|
||||
foreach ((NodeId agent, AgentRuntimeState state) in _states)
|
||||
{
|
||||
foreach (ConnectedProbeInfo probe in state.Probes)
|
||||
{
|
||||
if (probe.Id == probeId)
|
||||
{
|
||||
result.Add((agent, probe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Agent 断开时,它对 Probe 会话的认知也随之失效。</summary>
|
||||
public void MarkOffline(NodeId agent)
|
||||
{
|
||||
if (_states.TryGetValue(agent, out AgentRuntimeState? state))
|
||||
{
|
||||
state.ClearProbes();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null)
|
||||
{
|
||||
/// <summary>至少有一个 Probe 连着。</summary>
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。</summary>
|
||||
public sealed partial class AgentService
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TaskPlanner _planner;
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings)
|
||||
{
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_planner = planner;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
return [.. agents.Select(View)];
|
||||
}
|
||||
|
||||
public async ValueTask<AgentView?> GetAsync(NodeId id, CancellationToken ct) =>
|
||||
await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null;
|
||||
|
||||
public AgentView View(AgentRecord agent)
|
||||
{
|
||||
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
|
||||
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<AgentView>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (patch.Name is not null && !NamePattern().IsMatch(patch.Name))
|
||||
{
|
||||
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
|
||||
}
|
||||
|
||||
if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost))
|
||||
{
|
||||
errors["public_host"] = "must be a host name or IP address";
|
||||
}
|
||||
|
||||
if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32)))
|
||||
{
|
||||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<AgentView>(errors);
|
||||
}
|
||||
|
||||
Result<AgentRecord> result = await _agents.PatchAsync(id, patch, expectedRowVersion, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
if (patch.Name is not null || patch.PublicHost is not null)
|
||||
{
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
public ValueTask<Result<AgentView>> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
|
||||
|
||||
public ValueTask<Result<AgentView>> EnableAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
|
||||
|
||||
public async ValueTask<Result<AgentView>> DisableAsync(NodeId id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await ChangeStatusAsync(id, NodeStatus.Disabled, ct);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "disabled", ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<AgentView>> TakeoverAsync(NodeId pendingId, CancellationToken ct)
|
||||
{
|
||||
Result<AgentRecord> result = await _agents.TakeoverAsync(pendingId, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
// 公钥换了,旧连接使用的是旧密钥
|
||||
await _gateway.DisconnectAsync(agent.Id, "key replaced", ct);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
public async ValueTask DeleteAsync(NodeId id, CancellationToken ct)
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Probe 需要连接的 Agent 列表(所有已激活的 Agent)。版本号在列表变化时递增。</summary>
|
||||
public async ValueTask<ProbeAgentsResult> ProbeAgentsAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
|
||||
var result = new ProbeAgentsResult { Version = version };
|
||||
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0))
|
||||
{
|
||||
result.Agents.Add(new AgentEndpoint
|
||||
{
|
||||
AgentId = agent.Id,
|
||||
Name = agent.Name,
|
||||
Host = agent.PublicHost,
|
||||
TcpPort = agent.ProbeTcpPort,
|
||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||
Spki = Convert.ToBase64String(agent.TlsSpki),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Agent 集合变了:递增列表版本、重算各 Agent 的期望状态、通知 Probe。</summary>
|
||||
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
|
||||
{
|
||||
await _planner.BumpProbeAgentsVersionAsync(ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
await _gateway.NotifyProbeAgentsChangedAsync(await ProbeAgentsAsync(ct), ct);
|
||||
}
|
||||
|
||||
private async ValueTask<Result<AgentView>> ChangeStatusAsync(NodeId id, NodeStatus status, CancellationToken ct)
|
||||
{
|
||||
Result<AgentRecord> result = await _agents.SetStatusAsync(id, status, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
_gateway.NotifyTaskSetChanged(id);
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9.:\-\[\]]{1,253}$")]
|
||||
private static partial Regex HostPattern();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// <c>monitor.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
|
||||
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
|
||||
/// </summary>
|
||||
public sealed class HostStatusService
|
||||
{
|
||||
private static readonly TimeSpan s_stale = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IHostStore _store;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<NodeId, long> _lastSavedMinute = [];
|
||||
private readonly object _gate = new();
|
||||
|
||||
public HostStatusService(AgentRuntime runtime, IHostStore store, LiveHub live, TimeProvider time)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_store = store;
|
||||
_live = live;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask OnHostAsync(NodeId agent, HostSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
snapshot.AtUtcMs = now; // 以 Backend 收到的时间为准:节点的时钟不可信
|
||||
AgentRuntimeState state = _runtime.Get(agent);
|
||||
state.Host = snapshot;
|
||||
state.HostReceivedAtMs = now;
|
||||
_live.Publish(new HostSignal(agent, snapshot));
|
||||
|
||||
long minute = now - (now % 60_000);
|
||||
bool save;
|
||||
lock (_gate)
|
||||
{
|
||||
save = !_lastSavedMinute.TryGetValue(agent, out long last) || last != minute;
|
||||
if (save)
|
||||
{
|
||||
_lastSavedMinute[agent] = minute;
|
||||
}
|
||||
}
|
||||
|
||||
if (save)
|
||||
{
|
||||
await _store.SaveAsync(agent, minute, snapshot, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>最新快照;超过 15 秒没有更新就是过期(Agent 离线或者没有开启上报)。</summary>
|
||||
public (HostSnapshot? Host, bool Stale) Latest(NodeId agent)
|
||||
{
|
||||
if (!_runtime.TryGet(agent, out AgentRuntimeState? state) || state.Host is null)
|
||||
{
|
||||
return (null, true);
|
||||
}
|
||||
|
||||
return (state.Host, _time.GetUtcNow().ToUnixTimeMilliseconds() - state.HostReceivedAtMs > s_stale.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<HostSample>> HistoryAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct) => _store.ReadAsync(agent, fromMs, toMs, ct);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>Monitor 在 Fleet 之上的接入点:注册之后补全公开地址与端口。</summary>
|
||||
public sealed class MonitorEnrollmentHook : IEnrollmentHook
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentService _service;
|
||||
|
||||
public MonitorEnrollmentHook(IAgentStore agents, AgentService service)
|
||||
{
|
||||
_agents = agents;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
|
||||
{
|
||||
AgentEnrollMeta meta = new();
|
||||
if (context.ProductMeta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = raw.Deserialize(MonitorJsonContext.Default.AgentEnrollMeta) ?? meta;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 元数据格式不对就用默认值,节点之后可以通过 hello 补全
|
||||
}
|
||||
}
|
||||
|
||||
// public_host 未提供时,取注册请求的来源 IP
|
||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? "";
|
||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
||||
if (node.Status == NodeStatus.Active)
|
||||
{
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>节点上线、下线、hello、心跳:更新运行时状态,补全 Agent 信息,并推送给 SSE。</summary>
|
||||
public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly LiveHub _live;
|
||||
private readonly AgentService _service;
|
||||
|
||||
public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service)
|
||||
{
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_live = live;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct)
|
||||
{
|
||||
NodeId id = nodeEvent.Node.Id;
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_live.Publish(new NodeSignal("agent", id.Value, true, nodeEvent.AtMs));
|
||||
await _events.AddAsync(id, "agent_online", nodeEvent.AtMs, null, ct);
|
||||
break;
|
||||
case NodeEventKind.Offline:
|
||||
_runtime.MarkOffline(id);
|
||||
_live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs));
|
||||
await _events.AddAsync(id, "agent_offline", nodeEvent.AtMs, null, ct);
|
||||
break;
|
||||
case NodeEventKind.Hello when nodeEvent.Hello is { } hello:
|
||||
await ApplyHelloAsync(id, hello, ct);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
AgentRuntimeState state = _runtime.Get(id);
|
||||
AgentHeartbeatExtra? beat = extra.Deserialize(MonitorJsonContext.Default.AgentHeartbeatExtra);
|
||||
state.LastHeartbeat = beat;
|
||||
state.LastHeartbeatAtMs = nodeEvent.AtMs;
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ApplyHelloAsync(NodeId id, HelloParams hello, CancellationToken ct)
|
||||
{
|
||||
var kinds = new List<string>();
|
||||
foreach (string capability in hello.Capabilities)
|
||||
{
|
||||
if (capability.StartsWith(MonitorCapabilities.KindsPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
kinds.AddRange(capability[MonitorCapabilities.KindsPrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
}
|
||||
|
||||
AgentHelloMeta meta = new();
|
||||
if (hello.Meta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = raw.Deserialize(MonitorJsonContext.Default.AgentHelloMeta) ?? meta;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
byte[]? spki = null;
|
||||
if (meta.TlsSpki is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
spki = Convert.FromBase64String(meta.TlsSpki);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts), ct);
|
||||
|
||||
// 端口或证书指纹变了,Probe 需要新的列表
|
||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||
if (before is not null && after is not null
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts))
|
||||
&& after.Status == NodeStatus.Active)
|
||||
{
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(LocalLimits))]
|
||||
internal sealed partial class LocalLimitsJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record BootstrapAgent(NodeId AgentId, string Host, int TcpPort, string Spki);
|
||||
|
||||
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
|
||||
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
|
||||
|
||||
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥。</summary>
|
||||
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, IReadOnlyList<BootstrapAgent> Bootstrap, ProbeLimits Limits);
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs);
|
||||
|
||||
public sealed record ProbeView(ProbeRecord Probe, IReadOnlyList<ProbeConnection> Connections)
|
||||
{
|
||||
public bool Revoked => Probe.RevokedAtMs is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
|
||||
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话。
|
||||
/// 一条线路是一对(Probe,Agent)。
|
||||
/// </summary>
|
||||
public sealed partial class ProbeService
|
||||
{
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentService _agentService;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time)
|
||||
{
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
_agentService = agentService;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, CancellationToken ct)
|
||||
{
|
||||
string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim();
|
||||
if (!NamePattern().IsMatch(finalName))
|
||||
{
|
||||
return ValidationError.Fail<ProbeIdentityFile>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MonitorErrors.NameConflict, $"a probe named {finalName} already exists");
|
||||
}
|
||||
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
|
||||
await _probes.SaveAsync(probe, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
return Result<ProbeIdentityFile>.Ok(await BuildFileAsync(probe, identity, ct));
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
return [.. probes.Select(p => View(p, agents))];
|
||||
}
|
||||
|
||||
public async ValueTask<ProbeView?> GetAsync(string id, CancellationToken ct) =>
|
||||
await _probes.GetAsync(id, ct) is { } probe ? View(probe, await _agents.ListAsync(ct)) : null;
|
||||
|
||||
public async ValueTask<Result<ProbeView>> RenameAsync(string id, string name, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
||||
{
|
||||
return Result<ProbeView>.Fail(MonitorErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
string trimmed = name.Trim();
|
||||
if (!NamePattern().IsMatch(trimmed))
|
||||
{
|
||||
return ValidationError.Fail<ProbeView>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
if ((await _probes.ListAsync(ct)).Any(p => p.Id != id && string.Equals(p.Name, trimmed, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Result<ProbeView>.Fail(MonitorErrors.NameConflict, $"a probe named {trimmed} already exists");
|
||||
}
|
||||
|
||||
await _probes.SaveAsync(probe with { Name = trimmed }, ct);
|
||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
|
||||
/// <summary>换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。</summary>
|
||||
public async ValueTask<Result<ProbeIdentityFile>> RotateAsync(string id, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MonitorErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (probe.RevokedAtMs is not null)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MonitorErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead");
|
||||
}
|
||||
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki };
|
||||
await _probes.SaveAsync(updated, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
await DisconnectEverywhereAsync(id, ct);
|
||||
return Result<ProbeIdentityFile>.Ok(await BuildFileAsync(updated, identity, ct));
|
||||
}
|
||||
|
||||
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
|
||||
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is null)
|
||||
{
|
||||
return Result<ProbeView>.Fail(MonitorErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
|
||||
/// <summary>删除:Probe 身份和它的线路从面板消失;已经入库的数据按保留策略自然过期。</summary>
|
||||
public async ValueTask<bool> DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
bool deleted = await _probes.DeleteAsync(id, ct);
|
||||
if (deleted)
|
||||
{
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>让某个 Agent 断开这个 Probe(Probe 会按退避重连,除非它已经被吊销)。</summary>
|
||||
public async ValueTask<Result<bool>> DisconnectAsync(string probeId, NodeId agentId, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(probeId, ct) is null)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (await _agents.GetAsync(agentId, ct) is null)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
|
||||
}
|
||||
|
||||
private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct)
|
||||
{
|
||||
foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId))
|
||||
{
|
||||
await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
|
||||
{
|
||||
var connections = new List<ProbeConnection>();
|
||||
foreach ((NodeId agentId, ConnectedProbeInfo info) in _runtime.ConnectionsOf(probe.Id))
|
||||
{
|
||||
AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId);
|
||||
if (agent is not null)
|
||||
{
|
||||
connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs));
|
||||
}
|
||||
}
|
||||
|
||||
return new ProbeView(probe, [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private async ValueTask<ProbeIdentityFile> BuildFileAsync(ProbeRecord probe, NodeIdentity identity, CancellationToken ct)
|
||||
{
|
||||
ProbeAgentsResult agents = await _agentService.ProbeAgentsAsync(ct);
|
||||
return new ProbeIdentityFile(
|
||||
new ProbeId(probe.Id),
|
||||
Convert.ToBase64String(identity.ExportPkcs8()),
|
||||
[.. agents.Agents.Select(a => new BootstrapAgent(a.AgentId, a.Host, a.TcpPort, a.Spki))],
|
||||
new ProbeLimits(MaxTestMbps: 100, MaxTotalPps: 20_000));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Monitor.Backend.Analysis;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 持久路径:分钟窗口就绪(窗口结束 + 宽限,或迟到的块到达)之后从原始块计算 → 写入 metric_1m → 标记降采样脏桶;
|
||||
/// 然后把脏桶合并成 5 分钟、1 小时层。
|
||||
/// </summary>
|
||||
public sealed partial class AnalysisScheduler : BackgroundService
|
||||
{
|
||||
private const int BatchSize = 50;
|
||||
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly WindowComputer _computer;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly BackendCounters _counters;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<AnalysisScheduler> _logger;
|
||||
|
||||
public AnalysisScheduler(IMetricStore metrics, WindowComputer computer, BackendOptions options, BackendCounters counters, TimeProvider time, ILogger<AnalysisScheduler> logger)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_computer = computer;
|
||||
_options = options;
|
||||
_counters = counters;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
int worked;
|
||||
do
|
||||
{
|
||||
worked = await RunOnceAsync(stoppingToken);
|
||||
}
|
||||
while (worked >= BatchSize);
|
||||
|
||||
await Task.Delay(_options.AnalysisPollInterval, _time, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 后台循环不能因为一次失败而退出
|
||||
LogFailed(ex);
|
||||
await Task.Delay(_options.AnalysisPollInterval, _time, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>处理一批到期的窗口和脏桶,返回处理的窗口数(也供测试直接调用)。</summary>
|
||||
public async Task<int> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
IReadOnlyList<QueueItem> due = await _metrics.DequeueDueAsync(now, BatchSize, ct);
|
||||
foreach (QueueItem item in due)
|
||||
{
|
||||
await ComputeWindowAsync(item, ct);
|
||||
_counters.CountWindowAnalyzed();
|
||||
}
|
||||
|
||||
if (due.Count > 0)
|
||||
{
|
||||
await _metrics.CompleteAsync(due, ct);
|
||||
}
|
||||
|
||||
await RollupAsync(ct);
|
||||
return due.Count;
|
||||
}
|
||||
|
||||
private async ValueTask ComputeWindowAsync(QueueItem item, CancellationToken ct)
|
||||
{
|
||||
long width = MetricLevel.M1.WidthMs();
|
||||
WindowComputer.Computed computed = await _computer.ComputeAsync(item.AgentId, item.Series, item.WindowStart, item.WindowStart + width, width, null, ct);
|
||||
if (!computed.Buckets.TryGetValue(item.WindowStart, out MetricSet? set))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _metrics.UpsertAsync(MetricLevel.M1, [new MetricRow(item.AgentId, item.Series, item.WindowStart, computed.AnyPartial, AccumulatorCodec.Encode(set))], ct);
|
||||
await MarkUpperLevelsAsync(item.AgentId, item.Series, item.WindowStart, ct);
|
||||
}
|
||||
|
||||
internal async ValueTask MarkUpperLevelsAsync(NodeId agent, string series, long windowStart, CancellationToken ct)
|
||||
{
|
||||
await _metrics.MarkRollupDirtyAsync(MetricLevel.M5, agent, series, Floor(windowStart, MetricLevel.M5), ct);
|
||||
}
|
||||
|
||||
/// <summary>降采样:读取下一级的累加器状态,精确合并后写回。</summary>
|
||||
private async ValueTask RollupAsync(CancellationToken ct)
|
||||
{
|
||||
foreach ((MetricLevel level, NodeId agent, string series, long bucketStart) in await _metrics.TakeRollupDirtyAsync(100, ct))
|
||||
{
|
||||
MetricLevel source = level == MetricLevel.M5 ? MetricLevel.M1 : MetricLevel.M5;
|
||||
long end = bucketStart + level.WidthMs();
|
||||
IReadOnlyList<MetricRow> rows = await _metrics.ReadAsync(source, agent, series, bucketStart, end, ct);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var merged = new MetricSet();
|
||||
bool partial = false;
|
||||
foreach (MetricRow row in rows)
|
||||
{
|
||||
if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? set, out _))
|
||||
{
|
||||
merged.Merge(set);
|
||||
partial |= row.Partial;
|
||||
}
|
||||
}
|
||||
|
||||
await _metrics.UpsertAsync(level, [new MetricRow(agent, series, bucketStart, partial, AccumulatorCodec.Encode(merged))], ct);
|
||||
if (level == MetricLevel.M5)
|
||||
{
|
||||
await _metrics.MarkRollupDirtyAsync(MetricLevel.H1, agent, series, Floor(bucketStart, MetricLevel.H1), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static long Floor(long ms, MetricLevel level) => Math.DivRem(ms, level.WidthMs(), out _) * level.WidthMs();
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "analysis iteration failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Monitor.Backend.Analysis;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record RecomputeProgress(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, long? FromMs, long? ToMs);
|
||||
|
||||
/// <summary>
|
||||
/// 指标版本与回填:每个 <see cref="MetricDef"/> 有指纹(声明内容 + 算子签名)。启动时与 metric_versions 比对,
|
||||
/// 发现新增或变化的指标,就在保留期内的原始数据上重新计算这些指标(低优先级、限速),完成后记录新指纹。
|
||||
/// 回填结果只替换这些指标,其他指标保持不变;超出原始数据保留期的历史保留旧口径。
|
||||
/// </summary>
|
||||
public sealed partial class MetricRecompute : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(5);
|
||||
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly IRawStore _raw;
|
||||
private readonly WindowComputer _computer;
|
||||
private readonly AnalysisScheduler _scheduler;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<MetricRecompute> _logger;
|
||||
private RecomputeProgress _progress = new("idle", [], 0, 0, null, null);
|
||||
|
||||
public MetricRecompute(IMetricStore metrics, IRawStore raw, WindowComputer computer, AnalysisScheduler scheduler, BackendOptions options, TimeProvider time, ILogger<MetricRecompute> logger)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_raw = raw;
|
||||
_computer = computer;
|
||||
_scheduler = scheduler;
|
||||
_options = options;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public RecomputeProgress Progress => Volatile.Read(ref _progress);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// 正常停止
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailed(ex);
|
||||
Volatile.Write(ref _progress, Progress with { State = "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>比对指纹并回填(也供测试直接调用)。</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<MetricVersionRecord> known = await _metrics.GetVersionsAsync(ct);
|
||||
var knownById = known.ToDictionary(k => k.MetricId, k => k.Fingerprint, StringComparer.Ordinal);
|
||||
List<MetricDef> changed = [.. MetricRegistry.All.Where(m => !knownById.TryGetValue(m.Id, out string? fp) || fp != m.Fingerprint)];
|
||||
if (changed.Count == 0)
|
||||
{
|
||||
Volatile.Write(ref _progress, new RecomputeProgress("idle", [], 0, 0, null, null));
|
||||
return;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long from = now - (long)TimeSpan.FromDays(_options.RawRetentionDays).TotalMilliseconds;
|
||||
HashSet<string> ids = [.. changed.Select(m => m.Id)];
|
||||
IReadOnlyList<(NodeId AgentId, string Series)> series = await _raw.ListSeriesAsync(from, now, ct);
|
||||
|
||||
// 先数一遍窗口,用于进度
|
||||
var plan = new List<(NodeId Agent, string Series, List<long> Windows)>();
|
||||
long total = 0;
|
||||
foreach ((NodeId agent, string seriesId) in series)
|
||||
{
|
||||
List<long> windows = await ListWindowsAsync(agent, seriesId, from, now, ct);
|
||||
plan.Add((agent, seriesId, windows));
|
||||
total += windows.Count;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _progress, new RecomputeProgress("running", [.. ids], 0, total, from, now));
|
||||
long done = 0;
|
||||
long tenSecondsFloor = now - (long)TimeSpan.FromHours(_options.Metric10sRetentionHours).TotalMilliseconds;
|
||||
foreach ((NodeId agent, string seriesId, List<long> windows) in plan)
|
||||
{
|
||||
foreach (long window in windows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await RecomputeWindowAsync(agent, seriesId, window, ids, window >= tenSecondsFloor, ct);
|
||||
Volatile.Write(ref _progress, new RecomputeProgress("running", [.. ids], ++done, total, from, now));
|
||||
await Task.Delay(s_pause, _time, ct);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetricDef metric in changed)
|
||||
{
|
||||
await _metrics.SetVersionAsync(new MetricVersionRecord(metric.Id, metric.Fingerprint, null, null), ct);
|
||||
}
|
||||
|
||||
Volatile.Write(ref _progress, new RecomputeProgress("done", [.. ids], done, total, from, now));
|
||||
}
|
||||
|
||||
private async ValueTask<List<long>> ListWindowsAsync(NodeId agent, string series, long from, long to, CancellationToken ct)
|
||||
{
|
||||
long minute = MetricLevel.M1.WidthMs();
|
||||
var windows = new SortedSet<long>();
|
||||
long? cursor = null;
|
||||
while (true)
|
||||
{
|
||||
IReadOnlyList<RawChunkMeta> page = await _raw.FindAsync(new RawQuery(agent, series, FromMs: from, ToMs: to, AfterStartTs: cursor, Limit: 500), ct);
|
||||
foreach (RawChunkMeta meta in page.Where(m => m.TestId is null))
|
||||
{
|
||||
for (long w = Math.DivRem(meta.StartTs, minute, out _) * minute; w <= meta.StartTs + meta.DurationMs; w += minute)
|
||||
{
|
||||
windows.Add(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (page.Count < 500)
|
||||
{
|
||||
return [.. windows];
|
||||
}
|
||||
|
||||
cursor = page[^1].StartTs;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RecomputeWindowAsync(NodeId agent, string series, long window, ISet<string> only, bool includeTenSeconds, CancellationToken ct)
|
||||
{
|
||||
long minute = MetricLevel.M1.WidthMs();
|
||||
if (includeTenSeconds)
|
||||
{
|
||||
WindowComputer.Computed ten = await _computer.ComputeAsync(agent, series, window, window + minute, MetricLevel.S10.WidthMs(), only, ct);
|
||||
if (ten.Buckets.Count > 0)
|
||||
{
|
||||
await _metrics.PatchAsync(
|
||||
MetricLevel.S10,
|
||||
[.. ten.Buckets.Select(b => new MetricRow(agent, series, b.Key, ten.AnyPartial, AccumulatorCodec.Encode(b.Value)))],
|
||||
(IReadOnlySet<string>)only, ct);
|
||||
}
|
||||
}
|
||||
|
||||
WindowComputer.Computed one = await _computer.ComputeAsync(agent, series, window, window + minute, minute, only, ct);
|
||||
if (one.Buckets.TryGetValue(window, out MetricSet? set))
|
||||
{
|
||||
await _metrics.PatchAsync(MetricLevel.M1, [new MetricRow(agent, series, window, one.AnyPartial, AccumulatorCodec.Encode(set))], (IReadOnlySet<string>)only, ct);
|
||||
await _scheduler.MarkUpperLevelsAsync(agent, series, window, ct);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "metric recompute failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 从原始块重新计算一个窗口(持久路径与回填共用):读取与窗口重叠的块 → 评估 → 取出窗口对应的累加器。
|
||||
/// 完全由原始数据决定,所以重复执行结果相同(幂等)。
|
||||
/// </summary>
|
||||
public sealed class WindowComputer
|
||||
{
|
||||
/// <summary>块最长 10 分钟,所以起点早于窗口 10 分钟的块不会与窗口重叠。</summary>
|
||||
private const long MaxChunkSpanMs = 600_000;
|
||||
|
||||
private readonly IRawStore _raw;
|
||||
|
||||
public WindowComputer(IRawStore raw)
|
||||
{
|
||||
_raw = raw;
|
||||
}
|
||||
|
||||
public sealed record Computed(SortedDictionary<long, MetricSet> Buckets, bool AnyPartial, int ChunkCount);
|
||||
|
||||
/// <summary>
|
||||
/// 在 [windowStart, windowEnd) 内按 <paramref name="bucketMs"/> 分桶评估;<paramref name="only"/> 不为空时只算这些指标。
|
||||
/// </summary>
|
||||
public async ValueTask<Computed> ComputeAsync(NodeId agent, string series, long windowStart, long windowEnd, long bucketMs, ISet<string>? only, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<RawChunkMeta> metas = await _raw.FindAsync(
|
||||
new RawQuery(agent, series, FromMs: windowStart - MaxChunkSpanMs, ToMs: windowEnd, Limit: 2000), ct);
|
||||
var all = new SortedDictionary<long, MetricSet>();
|
||||
bool partial = false;
|
||||
int count = 0;
|
||||
foreach (RawChunkMeta meta in metas)
|
||||
{
|
||||
if (meta.TestId is not null || meta.StartTs + meta.DurationMs <= windowStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[]? bytes = await _raw.ReadAsync(meta, ct);
|
||||
if (bytes is null || !RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
partial |= meta.Partial;
|
||||
WindowEvaluator.Default.Evaluate(chunk, bucketMs, all, only);
|
||||
}
|
||||
|
||||
var inside = new SortedDictionary<long, MetricSet>();
|
||||
foreach ((long start, MetricSet set) in all)
|
||||
{
|
||||
if (start >= windowStart && start < windowEnd)
|
||||
{
|
||||
inside[start] = set;
|
||||
}
|
||||
}
|
||||
|
||||
return new Computed(inside, partial, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public static class ApplicationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 Application 层的全部服务。需要事先注册端口的实现(存储、<see cref="IAgentGateway"/>)和 <see cref="TimeProvider"/>。
|
||||
/// 后台任务按这里的顺序启动:<see cref="StartupTasks"/> 必须在最前面。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMonitorApplication(this IServiceCollection services, BackendOptions options)
|
||||
{
|
||||
services.AddSingleton(options);
|
||||
services.AddHostedService<StartupTasks>();
|
||||
|
||||
services.AddSingleton<AgentRuntime>();
|
||||
services.AddSingleton<LiveHub>();
|
||||
services.AddSingleton<HotLayer>();
|
||||
services.AddSingleton<BackendCounters>();
|
||||
services.AddSingleton<RawQueryGate>();
|
||||
services.AddSingleton<LoginRateLimiter>();
|
||||
|
||||
services.AddSingleton<TaskPlanner>();
|
||||
services.AddSingleton<TaskService>();
|
||||
services.AddSingleton<AgentService>();
|
||||
services.AddSingleton<AgentEventService>();
|
||||
services.AddSingleton<HostStatusService>();
|
||||
services.AddSingleton<ProbeService>();
|
||||
services.AddSingleton<LineService>();
|
||||
services.AddSingleton<TestService>();
|
||||
services.AddSingleton<IngestService>();
|
||||
services.AddSingleton<WindowComputer>();
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<JoinTokenService>();
|
||||
services.AddSingleton<RawBrowserService>();
|
||||
services.AddSingleton<EventService>();
|
||||
services.AddSingleton<OpsService>();
|
||||
|
||||
services.AddSingleton<RangeResolver>();
|
||||
services.AddSingleton<SourcePlanner>();
|
||||
services.AddSingleton<RowLoader>();
|
||||
services.AddSingleton<QueryService>();
|
||||
|
||||
services.AddSingleton<IEnrollmentHook, MonitorEnrollmentHook>();
|
||||
services.AddSingleton<INodeEventSink, MonitorNodeEventSink>();
|
||||
|
||||
services.AddSingleton<AnalysisScheduler>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<AnalysisScheduler>());
|
||||
services.AddSingleton<MetricRecompute>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<MetricRecompute>());
|
||||
services.AddSingleton<TestFinalizer>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>());
|
||||
services.AddHostedService<HotLayerBackfill>();
|
||||
services.AddHostedService<RetentionService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public enum PrincipalKind
|
||||
{
|
||||
Anonymous,
|
||||
Session,
|
||||
Token,
|
||||
}
|
||||
|
||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理。</summary>
|
||||
public enum ApiScope
|
||||
{
|
||||
None = 0,
|
||||
Read = 1,
|
||||
Admin = 2,
|
||||
}
|
||||
|
||||
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
|
||||
|
||||
public bool CanRead => Scope >= ApiScope.Read;
|
||||
|
||||
public bool IsAdmin => Scope >= ApiScope.Admin;
|
||||
}
|
||||
|
||||
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
||||
|
||||
public sealed record CreatedApiToken(ApiTokenRecord Record, string Plaintext);
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>面板用户、Cookie 会话、API 令牌。密码用 PBKDF2-SHA256,令牌和会话只保存 SHA-256。</summary>
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
private const int MinPasswordLength = 8;
|
||||
private const int MaxPasswordLength = 256;
|
||||
private const long TokenTouchIntervalMs = 60_000;
|
||||
|
||||
private readonly IAuthStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly byte[] _dummySalt = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
public AuthService(IAuthStore store, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>登录失败一律返回 unauthorized,不区分“用户不存在”和“密码错误”。</summary>
|
||||
public async ValueTask<Result<LoginResult>> LoginAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
UserRecord? user = await _store.GetUserByNameAsync(username, ct);
|
||||
byte[] salt = user?.PasswordSalt ?? _dummySalt;
|
||||
int iterations = user?.Iterations ?? _options.Pbkdf2Iterations;
|
||||
byte[] hash = Hash(password, salt, iterations);
|
||||
if (user is null || !CryptographicOperations.FixedTimeEquals(hash, user.PasswordHash))
|
||||
{
|
||||
return Result<LoginResult>.Fail(MonitorErrors.Unauthorized, "invalid credentials");
|
||||
}
|
||||
|
||||
byte[] token = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] csrf = RandomNumberGenerator.GetBytes(32);
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long expires = now + (long)_options.SessionLifetime.TotalMilliseconds;
|
||||
await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct);
|
||||
await _store.DeleteExpiredSessionsAsync(now, ct);
|
||||
return Result<LoginResult>.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username));
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateSessionAsync(string sessionToken, CancellationToken ct)
|
||||
{
|
||||
if (!Base64Url.TryDecode(sessionToken, out byte[] raw) || raw.Length != 32)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
SessionRecord? session = await _store.GetSessionAsync(SHA256.HashData(raw), ct);
|
||||
if (session is null || session.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
{
|
||||
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
|
||||
if (token is null || token.RevokedAtMs is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
|
||||
{
|
||||
await _store.TouchApiTokenAsync(token.Id, now, ct);
|
||||
}
|
||||
|
||||
ApiScope scope = token.Scope == "admin" ? ApiScope.Admin : ApiScope.Read;
|
||||
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
|
||||
}
|
||||
|
||||
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
|
||||
{
|
||||
if (Base64Url.TryDecode(sessionToken, out byte[] raw))
|
||||
{
|
||||
await _store.DeleteSessionAsync(SHA256.HashData(raw), ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<UserRecord>> CreateUserAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (!UsernamePattern().IsMatch(username))
|
||||
{
|
||||
errors["username"] = "must be 3-32 characters of letters, digits, '.', '_' or '-'";
|
||||
}
|
||||
|
||||
if (password.Length is < MinPasswordLength or > MaxPasswordLength)
|
||||
{
|
||||
errors["password"] = $"must be {MinPasswordLength}-{MaxPasswordLength} characters";
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<UserRecord>(errors);
|
||||
}
|
||||
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
var user = new UserRecord(Ulid.NewPrefixed("usr", _time), username, Hash(password, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, _time.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
return await _store.CreateUserAsync(user, ct)
|
||||
? Result<UserRecord>.Ok(user)
|
||||
: Result<UserRecord>.Fail(MonitorErrors.NameConflict, "username already exists");
|
||||
}
|
||||
|
||||
public async ValueTask<Result<bool>> ChangePasswordAsync(string userId, string newPassword, CancellationToken ct)
|
||||
{
|
||||
if (newPassword.Length is < MinPasswordLength or > MaxPasswordLength)
|
||||
{
|
||||
return ValidationError.Fail<bool>("password", $"must be {MinPasswordLength}-{MaxPasswordLength} characters");
|
||||
}
|
||||
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
await _store.UpdatePasswordAsync(userId, Hash(newPassword, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, ct);
|
||||
return Result<bool>.Ok(true);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
|
||||
|
||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
|
||||
{
|
||||
if (name.Length is 0 or > 64)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
|
||||
}
|
||||
|
||||
if (scope is not ("read" or "admin"))
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("scope", "must be read or admin");
|
||||
}
|
||||
|
||||
string plaintext = TokenHash.NewToken("mon");
|
||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null);
|
||||
await _store.CreateApiTokenAsync(record, ct);
|
||||
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct) => _store.ListApiTokensAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeApiTokenAsync(string id, CancellationToken ct) => _store.RevokeApiTokenAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
/// <summary>首次启动时(没有任何用户)用配置里的凭据创建初始管理员。</summary>
|
||||
public async ValueTask<bool> EnsureInitialAdminAsync(CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_options.InitialAdminUser) || string.IsNullOrEmpty(_options.InitialAdminPassword) || await _store.CountUsersAsync(ct) > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (await CreateUserAsync(_options.InitialAdminUser, _options.InitialAdminPassword, ct)).IsSuccess;
|
||||
}
|
||||
|
||||
public static bool FixedTimeEquals(string a, string b) =>
|
||||
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||
|
||||
private static byte[] Hash(string password, byte[] salt, int iterations) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, 32);
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9._-]{3,32}$")]
|
||||
private static partial Regex UsernamePattern();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>登录限流:每个来源在滑动的一分钟内最多尝试若干次。</summary>
|
||||
public sealed class LoginRateLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Queue<long>> _attempts = new();
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public LoginRateLimiter(BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。</summary>
|
||||
public bool TryAcquire(string source, out int retryAfterSeconds)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
Queue<long> queue = _attempts.GetOrAdd(source, _ => new Queue<long>());
|
||||
lock (queue)
|
||||
{
|
||||
while (queue.Count > 0 && now - queue.Peek() >= 60_000)
|
||||
{
|
||||
queue.Dequeue();
|
||||
}
|
||||
|
||||
if (queue.Count >= _options.LoginAttemptsPerMinute)
|
||||
{
|
||||
retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000);
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.Enqueue(now);
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>清理很久没有尝试的来源,避免字典无限增长。</summary>
|
||||
public void Prune()
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach ((string key, Queue<long> queue) in _attempts)
|
||||
{
|
||||
lock (queue)
|
||||
{
|
||||
if (queue.Count == 0 || now - queue.Peek() >= 60_000)
|
||||
{
|
||||
_attempts.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>进程内计数器,用于 <c>/metrics</c>(Prometheus 格式)。</summary>
|
||||
public sealed class BackendCounters
|
||||
{
|
||||
private long _chunksIngested;
|
||||
private long _chunksDuplicate;
|
||||
private long _chunksDiscarded;
|
||||
private long _bytesIngested;
|
||||
private long _sseConnections;
|
||||
private long _windowsAnalyzed;
|
||||
|
||||
public long ChunksIngested => Interlocked.Read(ref _chunksIngested);
|
||||
|
||||
public long ChunksDuplicate => Interlocked.Read(ref _chunksDuplicate);
|
||||
|
||||
public long ChunksDiscarded => Interlocked.Read(ref _chunksDiscarded);
|
||||
|
||||
public long BytesIngested => Interlocked.Read(ref _bytesIngested);
|
||||
|
||||
public long SseConnections => Interlocked.Read(ref _sseConnections);
|
||||
|
||||
public long WindowsAnalyzed => Interlocked.Read(ref _windowsAnalyzed);
|
||||
|
||||
public void CountIngested(long bytes)
|
||||
{
|
||||
Interlocked.Increment(ref _chunksIngested);
|
||||
Interlocked.Add(ref _bytesIngested, bytes);
|
||||
}
|
||||
|
||||
public void CountDuplicate() => Interlocked.Increment(ref _chunksDuplicate);
|
||||
|
||||
public void CountDiscarded() => Interlocked.Increment(ref _chunksDiscarded);
|
||||
|
||||
public void CountWindowAnalyzed() => Interlocked.Increment(ref _windowsAnalyzed);
|
||||
|
||||
public void SseOpened() => Interlocked.Increment(ref _sseConnections);
|
||||
|
||||
public void SseClosed() => Interlocked.Decrement(ref _sseConnections);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>Backend 的可调参数(保留期等也可以通过 settings 改,这里是默认值与硬上限)。</summary>
|
||||
public sealed class BackendOptions
|
||||
{
|
||||
/// <summary>数据目录:<c>monitor.db</c> 与 <c>raw/</c>。</summary>
|
||||
public string DataDirectory { get; set; } = "data";
|
||||
|
||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||||
|
||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||
public bool SecureCookies { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 允许跨域调用 API 的前端来源,逗号分隔(如 <c>http://localhost:5173,https://panel.example.com</c>,或 <c>*</c>);空 = 不允许,只有同源。
|
||||
/// 跨域只支持 Bearer 令牌(不带 Cookie),所以不影响会话的安全。
|
||||
/// </summary>
|
||||
public string? CorsOrigins { get; set; }
|
||||
|
||||
public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
public int LoginAttemptsPerMinute { get; set; } = 10;
|
||||
|
||||
public int RawRetentionDays { get; set; } = 30;
|
||||
|
||||
public int TestRawRetentionDays { get; set; } = 90;
|
||||
|
||||
public int Metric10sRetentionHours { get; set; } = 48;
|
||||
|
||||
public int Metric1mRetentionDays { get; set; } = 30;
|
||||
|
||||
public int Metric5mRetentionDays { get; set; } = 365;
|
||||
|
||||
public int EventRetentionDays { get; set; } = 365;
|
||||
|
||||
public int HostRetentionDays { get; set; } = 7;
|
||||
|
||||
/// <summary>分钟窗口在结束之后再等一会儿,让迟到的块先到。</summary>
|
||||
public TimeSpan WindowGrace { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
public TimeSpan AnalysisPollInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan QueryTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public int MaxSsePerClient { get; set; } = 4;
|
||||
|
||||
public int MaxSseTopics { get; set; } = 32;
|
||||
|
||||
public int MaxPointsPerSeries { get; set; } = 2000;
|
||||
|
||||
public int MaxMetricsPerQuery { get; set; } = 32;
|
||||
|
||||
public int MaxCompareAgents { get; set; } = 20;
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
public string? InitialAdminPassword { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Stored"/> 和 <see cref="Duplicate"/> 都会让节点把消息从发件箱里移除。
|
||||
/// <see cref="Discarded"/> 表示这条消息永远不可能被接受(损坏、冒名、未知 Kind、时间离谱),
|
||||
/// 同样确认掉,否则节点会无限重发——原因记入日志和事件。
|
||||
/// </summary>
|
||||
public enum IngestOutcome
|
||||
{
|
||||
Stored,
|
||||
Duplicate,
|
||||
Discarded,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 原始块入库:校验 → 追加到段文件并写索引 → 流式路径(1s 热层 + 10s 层)→ 登记分钟窗口等待持久路径计算。
|
||||
/// 持久化成功之后才返回,Fleet 随后才会 ACK。
|
||||
/// </summary>
|
||||
public sealed partial class IngestService
|
||||
{
|
||||
private const long MaxFutureSkewMs = 3_600_000;
|
||||
private const int MaxDurationMs = 600_000;
|
||||
|
||||
private readonly IRawStore _raw;
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly HotLayer _hot;
|
||||
private readonly LiveHub _live;
|
||||
private readonly BackendCounters _counters;
|
||||
private readonly IEventStore _events;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<IngestService> _logger;
|
||||
|
||||
public IngestService(
|
||||
IRawStore raw,
|
||||
IMetricStore metrics,
|
||||
HotLayer hot,
|
||||
LiveHub live,
|
||||
BackendCounters counters,
|
||||
IEventStore events,
|
||||
BackendOptions options,
|
||||
TimeProvider time,
|
||||
ILogger<IngestService> logger)
|
||||
{
|
||||
_raw = raw;
|
||||
_metrics = metrics;
|
||||
_hot = hot;
|
||||
_live = live;
|
||||
_counters = counters;
|
||||
_events = events;
|
||||
_options = options;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<IngestOutcome> IngestAsync(NodeId agent, Guid msgId, ReadOnlyMemory<byte> bytes, CancellationToken ct)
|
||||
{
|
||||
Result<RawChunk> decoded = RawChunkCodec.Decode(bytes.Span);
|
||||
if (!decoded.TryGet(out RawChunk? chunk, out FleetError? error))
|
||||
{
|
||||
return await DiscardAsync(agent, "undecodable chunk: " + error.Message, ct);
|
||||
}
|
||||
|
||||
RawChunkHeader header = chunk.Header;
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string? problem =
|
||||
header.AgentId != agent ? "agent_id does not match the authenticated node"
|
||||
: header.ChunkId.Value != msgId ? "chunk_id does not match the message id"
|
||||
: Kinds.Find(header.Kind) is null ? $"unknown kind {header.Kind}"
|
||||
: header.DurationMs is <= 0 or > MaxDurationMs ? "duration out of range"
|
||||
: header.StartUtcMs > now + MaxFutureSkewMs ? "chunk starts in the future"
|
||||
: header.StartUtcMs < now - (long)TimeSpan.FromDays(_options.TestRawRetentionDays).TotalMilliseconds ? "chunk is older than the retention"
|
||||
: null;
|
||||
if (problem is not null)
|
||||
{
|
||||
return await DiscardAsync(agent, problem, ct);
|
||||
}
|
||||
|
||||
string series = header.Series;
|
||||
var meta = new RawChunkMeta(
|
||||
header.ChunkId, agent, series, header.Kind, header.TaskId, header.TestId, header.Phase,
|
||||
header.StartUtcMs, header.DurationMs, header.Partial, "", 0, bytes.Length);
|
||||
bool stored = await _raw.AppendAsync(new RawChunkAppend(meta, bytes, DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(header.StartUtcMs).UtcDateTime).ToString("yyyy-MM-dd")), ct);
|
||||
if (!stored)
|
||||
{
|
||||
_counters.CountDuplicate();
|
||||
return IngestOutcome.Duplicate;
|
||||
}
|
||||
|
||||
_counters.CountIngested(bytes.Length);
|
||||
if (header.TestId is { } testId)
|
||||
{
|
||||
// 测试数据不进入持续线路的指标
|
||||
_live.Publish(new TestSignal(testId));
|
||||
return IngestOutcome.Stored;
|
||||
}
|
||||
|
||||
await FeedAnalysisAsync(agent, series, chunk, now, ct);
|
||||
_live.Publish(new ChunkSignal(agent, series));
|
||||
return IngestOutcome.Stored;
|
||||
}
|
||||
|
||||
private async ValueTask FeedAnalysisAsync(NodeId agent, string series, RawChunk chunk, long now, CancellationToken ct)
|
||||
{
|
||||
_hot.Merge(agent, series, chunk);
|
||||
|
||||
var tenSeconds = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, MetricLevel.S10.WidthMs(), tenSeconds);
|
||||
var rows = new List<MetricRow>(tenSeconds.Count);
|
||||
foreach ((long start, MetricSet set) in tenSeconds)
|
||||
{
|
||||
rows.Add(new MetricRow(agent, series, start, chunk.Header.Partial, AccumulatorCodec.Encode(set)));
|
||||
}
|
||||
|
||||
await _metrics.MergeAsync(MetricLevel.S10, rows, ct);
|
||||
|
||||
// 持久路径:块覆盖到的每个分钟窗口,等窗口结束再加宽限时间之后计算;迟到的块立即重算
|
||||
long minute = MetricLevel.M1.WidthMs();
|
||||
long first = Math.DivRem(chunk.Header.StartUtcMs, minute, out _) * minute;
|
||||
long last = Math.DivRem(chunk.Header.StartUtcMs + chunk.Header.DurationMs, minute, out _) * minute;
|
||||
var queue = new List<QueueItem>();
|
||||
for (long window = first; window <= last; window += minute)
|
||||
{
|
||||
long ready = window + minute + (long)_options.WindowGrace.TotalMilliseconds;
|
||||
queue.Add(new QueueItem(agent, series, window, Math.Max(now, ready), ready <= now ? "late_chunk" : "new"));
|
||||
}
|
||||
|
||||
await _metrics.EnqueueAsync(queue, ct);
|
||||
}
|
||||
|
||||
private async ValueTask<IngestOutcome> DiscardAsync(NodeId agent, string reason, CancellationToken ct)
|
||||
{
|
||||
_counters.CountDiscarded();
|
||||
LogDiscarded(agent.Value, reason);
|
||||
await _events.AddAsync(agent, "chunk_rejected", _time.GetUtcNow().ToUnixTimeMilliseconds(), reason, ct);
|
||||
return IngestOutcome.Discarded;
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "chunk discarded agent={Agent} err={Err}")]
|
||||
private partial void LogDiscarded(string agent, string err);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 1 秒精度的内存热层:最近 1 小时的累加器。桶以 <see cref="AccumulatorCodec"/> 编码后的字节保存,
|
||||
/// 15 条线路约十几 MB;它同时是 SSE 实时推送的数据来源。重启后为空,由后台任务从原始块回填。
|
||||
/// </summary>
|
||||
public sealed class HotLayer
|
||||
{
|
||||
public const long BucketMs = 1000;
|
||||
public static readonly TimeSpan Retention = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<(NodeId Agent, string Series), Ring> _rings = new();
|
||||
private readonly TimeProvider _time;
|
||||
private volatile bool _ready;
|
||||
|
||||
public HotLayer(TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>回填完成之前热层不完整,查询规划器不会选用它。</summary>
|
||||
public bool Ready => _ready;
|
||||
|
||||
public void MarkReady() => _ready = true;
|
||||
|
||||
public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys;
|
||||
|
||||
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
|
||||
public void Merge(NodeId agent, string series, RawChunk chunk)
|
||||
{
|
||||
Ring ring = _rings.GetOrAdd((agent, series), _ => new Ring());
|
||||
var buckets = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, BucketMs, buckets);
|
||||
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (long)Retention.TotalMilliseconds;
|
||||
lock (ring)
|
||||
{
|
||||
if (!ring.Chunks.Add(chunk.Header.ChunkId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((long start, MetricSet set) in buckets)
|
||||
{
|
||||
if (start < cutoff)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ring.Buckets.TryGetValue(start, out byte[]? existing) && AccumulatorCodec.Decode(existing).TryGet(out MetricSet? merged, out _))
|
||||
{
|
||||
merged.Merge(set);
|
||||
ring.Buckets[start] = AccumulatorCodec.Encode(merged);
|
||||
}
|
||||
else
|
||||
{
|
||||
ring.Buckets[start] = AccumulatorCodec.Encode(set);
|
||||
}
|
||||
}
|
||||
|
||||
Prune(ring, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>[from, to) 内的 1 秒桶(解码后的副本,按时间升序)。</summary>
|
||||
public IReadOnlyList<KeyValuePair<long, MetricSet>> GetRange(NodeId agent, string series, long fromMs, long toMs)
|
||||
{
|
||||
if (!_rings.TryGetValue((agent, series), out Ring? ring))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<KeyValuePair<long, MetricSet>>();
|
||||
lock (ring)
|
||||
{
|
||||
foreach ((long start, byte[] bytes) in ring.Buckets)
|
||||
{
|
||||
if (start >= toMs)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (start >= fromMs && AccumulatorCodec.Decode(bytes).TryGet(out MetricSet? set, out _))
|
||||
{
|
||||
result.Add(new KeyValuePair<long, MetricSet>(start, set));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public int BucketCount(NodeId agent, string series)
|
||||
{
|
||||
if (!_rings.TryGetValue((agent, series), out Ring? ring))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (ring)
|
||||
{
|
||||
return ring.Buckets.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(NodeId agent)
|
||||
{
|
||||
foreach ((NodeId Agent, string Series) key in _rings.Keys.Where(k => k.Agent == agent).ToList())
|
||||
{
|
||||
_rings.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Prune(Ring ring, long cutoff)
|
||||
{
|
||||
while (ring.Buckets.Count > 0)
|
||||
{
|
||||
long first = ring.Buckets.Keys.First();
|
||||
if (first >= cutoff)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ring.Buckets.Remove(first);
|
||||
}
|
||||
|
||||
// 块 ID 集合只需要覆盖热层的时间窗,限制大小即可
|
||||
if (ring.Chunks.Count > 2000)
|
||||
{
|
||||
ring.Chunks.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Ring
|
||||
{
|
||||
public SortedDictionary<long, byte[]> Buckets { get; } = [];
|
||||
|
||||
public HashSet<ChunkId> Chunks { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>重启后热层为空:从原始块回填最近 1 小时(低优先级、逐块让出)。完成之前查询规划器不会选用热层。</summary>
|
||||
public sealed partial class HotLayerBackfill : BackgroundService
|
||||
{
|
||||
private readonly HotLayer _hot;
|
||||
private readonly IRawStore _raw;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<HotLayerBackfill> _logger;
|
||||
|
||||
public HotLayerBackfill(HotLayer hot, IRawStore raw, TimeProvider time, ILogger<HotLayerBackfill> logger)
|
||||
{
|
||||
_hot = hot;
|
||||
_raw = raw;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// 正常停止
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 回填失败不应该让热层永远不可用:标记就绪,缺的部分会通过降级到 10s 层之外的查询体现
|
||||
LogFailed(ex);
|
||||
_hot.MarkReady();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>回填(也供测试直接调用)。</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long from = now - (long)HotLayer.Retention.TotalMilliseconds - 600_000;
|
||||
foreach ((NodeId agent, string series) in await _raw.ListSeriesAsync(from, now, ct))
|
||||
{
|
||||
IReadOnlyList<RawChunkMeta> metas = await _raw.FindAsync(new RawQuery(agent, series, FromMs: from, ToMs: now, Limit: 2000), ct);
|
||||
foreach (RawChunkMeta meta in metas.Where(m => m.TestId is null))
|
||||
{
|
||||
byte[]? bytes = await _raw.ReadAsync(meta, ct);
|
||||
if (bytes is not null && RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _))
|
||||
{
|
||||
_hot.Merge(agent, series, chunk);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
_hot.MarkReady();
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "hot layer backfill failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>发布到 SSE 订阅者的信号。SSE 只负责“更快地获得变化”,推送的内容都能由 REST 查询重建。</summary>
|
||||
public abstract record LiveSignal;
|
||||
|
||||
/// <summary>节点上下线等。</summary>
|
||||
public sealed record NodeSignal(string Kind, string Id, bool Online, long AtMs) : LiveSignal;
|
||||
|
||||
/// <summary>某条序列有新的原始块入库。</summary>
|
||||
public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal;
|
||||
|
||||
public sealed record TestSignal(TestId TestId) : LiveSignal;
|
||||
|
||||
/// <summary>某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。</summary>
|
||||
public sealed record HostSignal(NodeId AgentId, HostSnapshot Host) : LiveSignal;
|
||||
|
||||
public sealed class LiveSubscription : IDisposable
|
||||
{
|
||||
private readonly LiveHub _hub;
|
||||
private readonly Channel<LiveSignal> _channel = Channel.CreateBounded<LiveSignal>(new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||
|
||||
internal LiveSubscription(LiveHub hub)
|
||||
{
|
||||
_hub = hub;
|
||||
}
|
||||
|
||||
public ChannelReader<LiveSignal> Reader => _channel.Reader;
|
||||
|
||||
internal void Post(LiveSignal signal) => _channel.Writer.TryWrite(signal);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hub.Remove(this);
|
||||
_channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>内存中的发布订阅:入库、节点上下线、测试状态变化都经过这里。</summary>
|
||||
public sealed class LiveHub
|
||||
{
|
||||
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
|
||||
|
||||
public int SubscriberCount => _subscriptions.Count;
|
||||
|
||||
public LiveSubscription Subscribe()
|
||||
{
|
||||
var subscription = new LiveSubscription(this);
|
||||
_subscriptions[subscription] = 0;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public void Publish(LiveSignal signal)
|
||||
{
|
||||
foreach (LiveSubscription subscription in _subscriptions.Keys)
|
||||
{
|
||||
subscription.Post(signal);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Remove(LiveSubscription subscription) => _subscriptions.TryRemove(subscription, out _);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record AgentRecord(
|
||||
NodeId Id,
|
||||
string Name,
|
||||
NodeStatus Status,
|
||||
byte[] PublicKey,
|
||||
byte[] TlsSpki,
|
||||
string PublicHost,
|
||||
int ProbeTcpPort,
|
||||
IReadOnlyList<int> ProbeUdpPorts,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool PublicVisible,
|
||||
IReadOnlyList<string> Kinds,
|
||||
string? AgentVersion,
|
||||
string? LocalLimitsJson,
|
||||
long TaskVersion,
|
||||
long AppliedTaskVersion,
|
||||
string? RejectedJson,
|
||||
long CreatedAtMs,
|
||||
long? LastSeenAtMs,
|
||||
long RowVersion);
|
||||
|
||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||
public sealed class AgentPatch
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? Labels { get; init; }
|
||||
|
||||
public string? PublicHost { get; init; }
|
||||
|
||||
public bool? PublicVisible { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>来自 <c>fleet.hello</c> 的信息。</summary>
|
||||
public sealed record AgentHello(
|
||||
IReadOnlyList<string> Kinds,
|
||||
string? AgentVersion,
|
||||
string? LocalLimitsJson,
|
||||
byte[]? TlsSpki,
|
||||
int? ProbeTcpPort,
|
||||
IReadOnlyList<int>? ProbeUdpPorts);
|
||||
|
||||
/// <summary>注册之后 Monitor 的补充:公开地址和端口。</summary>
|
||||
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> Kinds);
|
||||
|
||||
public sealed record JoinTokenInfo(
|
||||
string Id,
|
||||
string? Note,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool AutoApprove,
|
||||
int MaxUses,
|
||||
int Uses,
|
||||
long ExpiresAtMs,
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs);
|
||||
|
||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs);
|
||||
@@ -0,0 +1,64 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>原始块的索引信息(块本身在段文件里)。</summary>
|
||||
public sealed record RawChunkMeta(
|
||||
ChunkId ChunkId,
|
||||
NodeId AgentId,
|
||||
string Series,
|
||||
string Kind,
|
||||
TaskId? TaskId,
|
||||
TestId? TestId,
|
||||
int? Phase,
|
||||
long StartTs,
|
||||
int DurationMs,
|
||||
bool Partial,
|
||||
string SegmentPath,
|
||||
long Offset,
|
||||
int Length);
|
||||
|
||||
/// <summary>写入一个原始块:块的完整字节加上用于索引的元数据。</summary>
|
||||
public sealed record RawChunkAppend(RawChunkMeta Meta, ReadOnlyMemory<byte> Bytes, string Day);
|
||||
|
||||
public sealed record RawQuery(
|
||||
NodeId? AgentId = null,
|
||||
string? Series = null,
|
||||
string? Kind = null,
|
||||
TestId? TestId = null,
|
||||
long? FromMs = null,
|
||||
long? ToMs = null,
|
||||
long? AfterStartTs = null,
|
||||
int Limit = 500);
|
||||
|
||||
public sealed record MetricRow(NodeId AgentId, string Series, long WindowStart, bool Partial, byte[] Metrics);
|
||||
|
||||
/// <summary><paramref name="Seq"/> 每次入队递增:处理完成时只删除序号没变的项,处理期间又有迟到的块时不会丢更新。</summary>
|
||||
public sealed record QueueItem(NodeId AgentId, string Series, long WindowStart, long NotBefore, string Reason, long Seq = 0);
|
||||
|
||||
public sealed record MetricVersionRecord(string MetricId, string Fingerprint, long? RecomputeFrom, long? RecomputeCursor);
|
||||
|
||||
/// <summary>指标窗口的层级。数值是窗口宽度(秒)。</summary>
|
||||
public enum MetricLevel
|
||||
{
|
||||
S10 = 10,
|
||||
M1 = 60,
|
||||
M5 = 300,
|
||||
H1 = 3600,
|
||||
}
|
||||
|
||||
public static class MetricLevels
|
||||
{
|
||||
public static readonly IReadOnlyList<MetricLevel> All = [MetricLevel.S10, MetricLevel.M1, MetricLevel.M5, MetricLevel.H1];
|
||||
|
||||
public static long WidthMs(this MetricLevel level) => (long)level * 1000;
|
||||
|
||||
public static string TableName(this MetricLevel level) => level switch
|
||||
{
|
||||
MetricLevel.S10 => "metric_10s",
|
||||
MetricLevel.M1 => "metric_1m",
|
||||
MetricLevel.M5 => "metric_5m",
|
||||
_ => "metric_1h",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record TaskRecord(
|
||||
TaskId Id,
|
||||
string Scope,
|
||||
NodeId? AgentId,
|
||||
TaskId? BaseTaskId,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement> Params,
|
||||
string Schedule,
|
||||
bool Enabled,
|
||||
long UpdatedAtMs,
|
||||
long RowVersion);
|
||||
|
||||
public sealed record TestRecord(
|
||||
TestId Id,
|
||||
NodeId AgentId,
|
||||
ProbeId ProbeId,
|
||||
string Plan,
|
||||
string ParamsJson,
|
||||
string Trigger,
|
||||
string Status,
|
||||
string? ErrorCode,
|
||||
int? Phase,
|
||||
int PhaseCount,
|
||||
long RequestedAtMs,
|
||||
long? StartedAtMs,
|
||||
long? FinishedAtMs);
|
||||
|
||||
/// <summary>测试结果:指标与曲线都以 JSON 保存。</summary>
|
||||
public sealed record TestResultRecord(TestId TestId, byte[] MetricsJson, byte[] SeriesJson);
|
||||
|
||||
public sealed record EventRecord(long Id, NodeId? AgentId, string Kind, long AtMs, string? Detail);
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Monitor.Backend.Analysis\Monitor.Backend.Analysis.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Application 通往 Agent 连接的端口(由 Gateway 实现):下发状态变化、发起按需测试、通知 Probe 列表变化。
|
||||
/// </summary>
|
||||
public interface IAgentGateway
|
||||
{
|
||||
IReadOnlyCollection<NodeId> OnlineAgents { get; }
|
||||
|
||||
bool IsOnline(NodeId agent);
|
||||
|
||||
/// <summary>某个 Agent 的期望状态变了:Gateway 重新计算并推送。</summary>
|
||||
void NotifyTaskSetChanged(NodeId agent);
|
||||
|
||||
void NotifyAllTaskSetsChanged();
|
||||
|
||||
/// <summary>Agent 列表变了:通知所有在线 Agent 转发给 Probe。</summary>
|
||||
ValueTask NotifyProbeAgentsChangedAsync(ProbeAgentsResult agents, CancellationToken ct);
|
||||
|
||||
/// <summary>失败时错误码为 <c>probe_offline</c>、<c>test_busy</c>、<c>agent_offline</c> 等。</summary>
|
||||
ValueTask<Result<bool>> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct);
|
||||
|
||||
ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct);
|
||||
|
||||
/// <summary>让 Agent 断开某个 Probe 的会话。返回 Agent 是否真的有这个会话。</summary>
|
||||
ValueTask<Result<bool>> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct);
|
||||
|
||||
ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs);
|
||||
|
||||
public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs);
|
||||
|
||||
public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs);
|
||||
|
||||
public interface IAuthStore
|
||||
{
|
||||
ValueTask<int> CountUsersAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<UserRecord?> GetUserByNameAsync(string username, CancellationToken ct);
|
||||
|
||||
ValueTask<UserRecord?> GetUserAsync(string id, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>用户名已存在时返回 false。</summary>
|
||||
ValueTask<bool> CreateUserAsync(UserRecord user, CancellationToken ct);
|
||||
|
||||
ValueTask UpdatePasswordAsync(string userId, byte[] hash, byte[] salt, int iterations, CancellationToken ct);
|
||||
|
||||
ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct);
|
||||
|
||||
ValueTask<SessionRecord?> GetSessionAsync(byte[] idSha256, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteExpiredSessionsAsync(long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct);
|
||||
|
||||
ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<bool> RevokeApiTokenAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public interface IAgentStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<AgentRecord>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<AgentRecord?> GetAsync(NodeId id, CancellationToken ct);
|
||||
|
||||
/// <summary>注册之后补充公开地址与端口(仅在记录还是注册时的默认值时才写入)。</summary>
|
||||
ValueTask ApplyEnrollmentAsync(NodeId id, AgentEnrollment enrollment, CancellationToken ct);
|
||||
|
||||
/// <summary>应用 merge-patch。<paramref name="expectedRowVersion"/> 不为 null 且不匹配时返回 precondition_failed。</summary>
|
||||
ValueTask<Result<AgentRecord>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct);
|
||||
|
||||
/// <summary>pending → active、active ↔ disabled。激活时名称不能与其他已建立的节点冲突。</summary>
|
||||
ValueTask<Result<AgentRecord>> SetStatusAsync(NodeId id, NodeStatus status, CancellationToken ct);
|
||||
|
||||
/// <summary>接管:把同名旧节点的公钥换成待审批节点的公钥,保留历史数据,并删除待审批记录。</summary>
|
||||
ValueTask<Result<AgentRecord>> TakeoverAsync(NodeId pendingId, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteAsync(NodeId id, CancellationToken ct);
|
||||
|
||||
ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct);
|
||||
|
||||
ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct);
|
||||
|
||||
ValueTask<string?> GetTaskHashAsync(NodeId id, CancellationToken ct);
|
||||
|
||||
ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IJoinTokenStore
|
||||
{
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IProbeStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct);
|
||||
|
||||
/// <summary>新增或整条更新(轮换密钥、改名都走这里)。</summary>
|
||||
ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct);
|
||||
|
||||
/// <summary>吊销:保留记录,公钥立即从所有 Agent 的名单里移除。已经吊销的返回 false。</summary>
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ISettingsStore
|
||||
{
|
||||
ValueTask<string?> GetAsync(string key, CancellationToken ct);
|
||||
|
||||
ValueTask SetAsync(string key, string value, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>原子地加一并返回新值(用于版本号计数器)。</summary>
|
||||
ValueTask<long> IncrementAsync(string key, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ITaskStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<TaskRecord>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<TaskRecord?> GetAsync(TaskId id, CancellationToken ct);
|
||||
|
||||
ValueTask CreateAsync(TaskRecord task, CancellationToken ct);
|
||||
|
||||
ValueTask<Result<TaskRecord>> UpdateAsync(TaskRecord task, long? expectedRowVersion, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteAsync(TaskId id, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ITestStore
|
||||
{
|
||||
ValueTask CreateAsync(TestRecord test, CancellationToken ct);
|
||||
|
||||
ValueTask<TestRecord?> GetAsync(TestId id, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct);
|
||||
|
||||
ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct);
|
||||
|
||||
ValueTask<TestResultRecord?> GetResultAsync(TestId id, CancellationToken ct);
|
||||
|
||||
/// <summary>已成功结束但还没有结果的测试(用于补算结果)。</summary>
|
||||
ValueTask<IReadOnlyList<TestRecord>> ListNeedingResultAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>把长时间处于 queued/running 的测试标记为 failed(例如 Backend 重启或节点失联)。</summary>
|
||||
ValueTask<int> FailStaleAsync(long olderThanMs, string errorCode, long nowMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IEventStore
|
||||
{
|
||||
ValueTask AddAsync(NodeId? agentId, string kind, long atMs, string? detail, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<EventRecord>> ListAsync(NodeId? agentId, string? kind, long fromMs, long toMs, long? beforeId, int limit, CancellationToken ct);
|
||||
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>主机资源占用的历史:每个 Agent 每分钟一个样本。</summary>
|
||||
public interface IHostStore
|
||||
{
|
||||
ValueTask SaveAsync(NodeId agent, long minuteMs, Monitor.Protocol.HostSnapshot snapshot, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<HostSample>> ReadAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct);
|
||||
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record HostSample(long AtMs, double? Cpu, long? MemUsed, long? MemTotal, long? DiskUsed, long? DiskTotal, long? NetRxBps, long? NetTxBps, long? UptimeSeconds);
|
||||
|
||||
public interface IRawStore
|
||||
{
|
||||
/// <summary>追加写入段文件 → 刷盘 → 写索引;<c>chunk_id</c> 已存在时返回 false(调用方仍然确认)。</summary>
|
||||
ValueTask<bool> AppendAsync(RawChunkAppend chunk, CancellationToken ct);
|
||||
|
||||
ValueTask<RawChunkMeta?> GetMetaAsync(ChunkId id, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<RawChunkMeta>> FindAsync(RawQuery query, CancellationToken ct);
|
||||
|
||||
ValueTask<byte[]?> ReadAsync(RawChunkMeta meta, CancellationToken ct);
|
||||
|
||||
/// <summary>删除早于截止日期的段文件和索引。持续任务与按需测试保留期不同。</summary>
|
||||
ValueTask<int> DeleteExpiredAsync(DateOnly continuousBefore, DateOnly testsBefore, CancellationToken ct);
|
||||
|
||||
/// <summary>时间范围内有原始块的序列(用于重算)。</summary>
|
||||
ValueTask<IReadOnlyList<(NodeId AgentId, string Series)>> ListSeriesAsync(long fromMs, long toMs, CancellationToken ct);
|
||||
|
||||
/// <summary>启动时截断没有索引记录的段文件尾部。</summary>
|
||||
ValueTask RecoverAsync(CancellationToken ct);
|
||||
|
||||
long TotalBytes { get; }
|
||||
}
|
||||
|
||||
public interface IMetricStore
|
||||
{
|
||||
/// <summary>覆盖写入(recompute 路径)。</summary>
|
||||
ValueTask UpsertAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, CancellationToken ct);
|
||||
|
||||
/// <summary>读取—合并—写回(流式路径:同一个窗口由多个块累加)。</summary>
|
||||
ValueTask MergeAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, CancellationToken ct);
|
||||
|
||||
/// <summary>用新算出的指标替换已有行里的同名指标,其余指标保持不变(回填新增或变更的指标)。</summary>
|
||||
ValueTask PatchAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, IReadOnlySet<string> replaceMetricIds, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<MetricRow>> ReadAsync(MetricLevel level, NodeId agentId, string series, long fromMs, long toMs, CancellationToken ct);
|
||||
|
||||
/// <summary>这条序列在某一层里最早的窗口起点;没有数据返回 null。</summary>
|
||||
ValueTask<long?> EarliestAsync(MetricLevel level, NodeId agentId, string series, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<MetricRow>> ReadAgentsAsync(MetricLevel level, IReadOnlyCollection<NodeId> agentIds, string series, long fromMs, long toMs, CancellationToken ct);
|
||||
|
||||
ValueTask<int> DeleteOlderThanAsync(MetricLevel level, long cutoffMs, CancellationToken ct);
|
||||
|
||||
ValueTask EnqueueAsync(IReadOnlyList<QueueItem> items, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<QueueItem>> DequeueDueAsync(long nowMs, int limit, CancellationToken ct);
|
||||
|
||||
ValueTask CompleteAsync(IReadOnlyList<QueueItem> items, CancellationToken ct);
|
||||
|
||||
ValueTask<int> QueueLengthAsync(CancellationToken ct);
|
||||
|
||||
ValueTask MarkRollupDirtyAsync(MetricLevel level, NodeId agentId, string series, long bucketStart, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<(MetricLevel Level, NodeId AgentId, string Series, long BucketStart)>> TakeRollupDirtyAsync(int limit, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<MetricVersionRecord>> GetVersionsAsync(CancellationToken ct);
|
||||
|
||||
ValueTask SetVersionAsync(MetricVersionRecord version, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record StorageHealth(bool Database, bool RawDirectory);
|
||||
|
||||
/// <summary>/healthz 用:SQLite 与原始数据目录都可写。</summary>
|
||||
public interface IStorageHealth
|
||||
{
|
||||
ValueTask<StorageHealth> CheckAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Monitor.Backend.Analysis;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>把源行合并成查询步长的桶:计数相加、直方图逐桶相加,所以结果是精确的,不是各点的平均。</summary>
|
||||
public sealed class BucketedRows
|
||||
{
|
||||
public BucketedRows(long[] t, MetricSet?[] sets, int[] rowCounts)
|
||||
{
|
||||
T = t;
|
||||
Sets = sets;
|
||||
RowCounts = rowCounts;
|
||||
}
|
||||
|
||||
public long[] T { get; }
|
||||
|
||||
public MetricSet?[] Sets { get; }
|
||||
|
||||
/// <summary>每个桶合并了多少个源行(用来估算持续时长和覆盖率)。</summary>
|
||||
public int[] RowCounts { get; }
|
||||
|
||||
public int TotalRows => RowCounts.Sum();
|
||||
|
||||
public static BucketedRows Create(IReadOnlyList<SourceRow> rows, QueryRange range)
|
||||
{
|
||||
int n = range.Points;
|
||||
long[] t = new long[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
t[i] = range.FromMs + (i * range.StepMs);
|
||||
}
|
||||
|
||||
var sets = new MetricSet?[n];
|
||||
int[] counts = new int[n];
|
||||
foreach (SourceRow row in rows)
|
||||
{
|
||||
long index = (row.Start - range.FromMs) / range.StepMs;
|
||||
if (row.Start < range.FromMs || index >= n)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int i = (int)index;
|
||||
if (sets[i] is null)
|
||||
{
|
||||
sets[i] = row.Set.Clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
sets[i]!.Merge(row.Set);
|
||||
}
|
||||
|
||||
counts[i]++;
|
||||
}
|
||||
|
||||
return new BucketedRows(t, sets, counts);
|
||||
}
|
||||
|
||||
/// <summary>整个时间段的精确合并。</summary>
|
||||
public MetricSet Merge()
|
||||
{
|
||||
var merged = new MetricSet();
|
||||
foreach (MetricSet? set in Sets)
|
||||
{
|
||||
if (set is not null)
|
||||
{
|
||||
merged.Merge(set);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>一条线路 = 一对(Agent,Probe)。存储里它的序列是 <c>line:<probe_id></c>,对外的 id 是 <c><agent_id>~<probe_id></c>。</summary>
|
||||
public readonly record struct LineKey(NodeId Agent, ProbeId Probe)
|
||||
{
|
||||
public string Series => RawChunkHeader.LinePrefix + Probe.Value;
|
||||
|
||||
public string Id => Agent.Value + "~" + Probe.Value;
|
||||
|
||||
public static bool TryParse(string text, out LineKey key)
|
||||
{
|
||||
int split = text.IndexOf('~', StringComparison.Ordinal);
|
||||
if (split <= 0 || split == text.Length - 1)
|
||||
{
|
||||
key = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new LineKey(new NodeId(text[..split]), new ProbeId(text[(split + 1)..]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe)
|
||||
{
|
||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id));
|
||||
|
||||
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id);
|
||||
|
||||
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id);
|
||||
}
|
||||
|
||||
/// <summary>线路目录:把 id 解析成(Agent,Probe),列出所有线路。</summary>
|
||||
public sealed class LineService
|
||||
{
|
||||
private readonly AgentService _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
|
||||
public LineService(AgentService agents, IProbeStore probes)
|
||||
{
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
}
|
||||
|
||||
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对。</summary>
|
||||
public async ValueTask<IReadOnlyList<LineView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentView> agents = await _agents.ListAsync(ct);
|
||||
IReadOnlyList<ProbeRecord> probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)];
|
||||
return [.. agents.Where(include).SelectMany(a => probes.Select(p => new LineView(a, p)))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 线路 id 是 <c>agent~probe</c>。只给 Agent id 时:恰好有一个未吊销的 Probe 就用它,否则要求明确指定(<c>probe_required</c>)。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<LineView>> ResolveAsync(string id, CancellationToken ct)
|
||||
{
|
||||
string agentPart = id;
|
||||
string? probePart = null;
|
||||
if (id.Contains('~', StringComparison.Ordinal))
|
||||
{
|
||||
if (!LineKey.TryParse(id, out LineKey key))
|
||||
{
|
||||
return Result<LineView>.Fail(MonitorErrors.AgentNotFound, "line not found");
|
||||
}
|
||||
|
||||
agentPart = key.Agent.Value;
|
||||
probePart = key.Probe.Value;
|
||||
}
|
||||
|
||||
AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct);
|
||||
if (agent is null)
|
||||
{
|
||||
return Result<LineView>.Fail(MonitorErrors.AgentNotFound, "line not found");
|
||||
}
|
||||
|
||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||
ProbeRecord? probe;
|
||||
if (probePart is not null)
|
||||
{
|
||||
probe = probes.FirstOrDefault(p => p.Id == probePart);
|
||||
if (probe is null)
|
||||
{
|
||||
return Result<LineView>.Fail(MonitorErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)];
|
||||
if (active.Length != 1)
|
||||
{
|
||||
return Result<LineView>.Fail(MonitorErrors.ProbeRequired, active.Length == 0 ? "there is no active probe" : "several probes exist: use <agent_id>~<probe_id> as the line id");
|
||||
}
|
||||
|
||||
probe = active[0];
|
||||
}
|
||||
|
||||
return Result<LineView>.Ok(new LineView(agent, probe));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>请求的指标:导出 id(<c>rtt.p95</c>)与评分 id(<c>score.mc</c>)。</summary>
|
||||
public sealed record MetricSelection(IReadOnlyList<string> ExportIds, IReadOnlyList<string> ScoreModelIds)
|
||||
{
|
||||
public bool WantsScores => ScoreModelIds.Count > 0;
|
||||
|
||||
public IEnumerable<string> AllIds => ExportIds.Concat(ScoreModelIds.Select(m => "score." + m));
|
||||
|
||||
public static Result<MetricSelection> Resolve(string? metrics, string? presets, int maxMetrics)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
if (presets is not null)
|
||||
{
|
||||
foreach (string presetId in presets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
PresetDef? preset = MetricRegistry.FindPreset(presetId);
|
||||
if (preset is null)
|
||||
{
|
||||
return Result<MetricSelection>.Fail(MonitorErrors.UnknownPreset, $"unknown preset {presetId}");
|
||||
}
|
||||
|
||||
ids.AddRange(preset.MetricIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics is not null)
|
||||
{
|
||||
ids.AddRange(metrics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
ids = [.. ids.Distinct(StringComparer.Ordinal)];
|
||||
if (ids.Count > maxMetrics)
|
||||
{
|
||||
return Result<MetricSelection>.Fail(MonitorErrors.TooManyMetrics, $"at most {maxMetrics} metrics per request");
|
||||
}
|
||||
|
||||
var exports = new List<string>();
|
||||
var scores = new List<string>();
|
||||
foreach (string id in ids)
|
||||
{
|
||||
if (id.StartsWith("score.", StringComparison.Ordinal))
|
||||
{
|
||||
string model = id["score.".Length..];
|
||||
if (ModelRegistry.Find(model) is null)
|
||||
{
|
||||
return Result<MetricSelection>.Fail(MonitorErrors.UnknownMetric, $"unknown metric {id}");
|
||||
}
|
||||
|
||||
scores.Add(model);
|
||||
}
|
||||
else if (MetricRegistry.TryFindExport(id, out _, out _))
|
||||
{
|
||||
exports.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Result<MetricSelection>.Fail(MonitorErrors.UnknownMetric, $"unknown metric {id}");
|
||||
}
|
||||
}
|
||||
|
||||
return Result<MetricSelection>.Ok(new MetricSelection(exports, scores));
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExportEvaluator
|
||||
{
|
||||
/// <summary>导出指定的值;没有样本的指标不出现在结果里。</summary>
|
||||
public static Dictionary<string, double> Evaluate(MetricSet set, IEnumerable<string> exportIds, double durationSeconds)
|
||||
{
|
||||
var values = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
foreach (string id in exportIds)
|
||||
{
|
||||
if (MetricRegistry.TryFindExport(id, out MetricDef def, out MetricExport export)
|
||||
&& set.TryGet(def.Id, out Accumulator acc)
|
||||
&& MetricDef.Export(acc, export, durationSeconds) is { } value
|
||||
&& double.IsFinite(value))
|
||||
{
|
||||
values[id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AllExportIds() => MetricRegistry.AllExports().Select(e => e.Export.Id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record SummaryResult(
|
||||
IReadOnlyDictionary<string, double?> Values,
|
||||
IReadOnlyDictionary<string, ulong> Samples,
|
||||
double Coverage);
|
||||
|
||||
public sealed record GapInfo(long FromMs, long ToMs, string Reason);
|
||||
|
||||
public sealed record SeriesResult(
|
||||
LineKey Line,
|
||||
QueryRange Range,
|
||||
SourcePlan Plan,
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyDictionary<string, double?[]> Values,
|
||||
SummaryResult Summary,
|
||||
IReadOnlyList<GapInfo> Gaps,
|
||||
IReadOnlyList<EventRecord> Events,
|
||||
IReadOnlyDictionary<string, string> Versions);
|
||||
|
||||
public sealed record OverviewLine(LineView Line, SummaryResult Summary, TestRecord? LastTest);
|
||||
|
||||
public sealed record OverviewResult(QueryRange Range, IReadOnlyList<OverviewLine> Lines, SourcePlan Plan);
|
||||
|
||||
public sealed record DistributionBucket(double Le, ulong Count);
|
||||
|
||||
public sealed record DistributionResult(
|
||||
string Metric,
|
||||
string Unit,
|
||||
IReadOnlyList<DistributionBucket> Buckets,
|
||||
double? P50,
|
||||
double? P95,
|
||||
double? P99,
|
||||
ulong Count);
|
||||
|
||||
public sealed record HeatmapResult(IReadOnlyList<long> T, IReadOnlyList<double> Edges, IReadOnlyList<IReadOnlyList<ulong>> Counts, QueryRange Range, SourcePlan Plan);
|
||||
|
||||
public sealed record CompareResult(
|
||||
IReadOnlyList<long> T,
|
||||
IReadOnlyDictionary<string, double?[]> Values,
|
||||
IReadOnlyDictionary<string, double?> Summary,
|
||||
string Metric,
|
||||
QueryRange Range,
|
||||
SourcePlan Plan);
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 展示数据查询:任意时间段、自动步长、数据分层与现场合并、精确汇总、列式结果。
|
||||
/// 新增指标时这套代码不需要任何改动:它只通过注册表按 id 读取累加器并导出值。
|
||||
/// </summary>
|
||||
public sealed class QueryService
|
||||
{
|
||||
/// <summary>热力图的延迟桶边界(毫秒)。</summary>
|
||||
public static readonly IReadOnlyList<double> HeatmapEdges =
|
||||
[1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 70, 100, 150, 200, 300, 500, 1000, 2000, 5000, 30000];
|
||||
|
||||
private readonly RangeResolver _resolver;
|
||||
private readonly SourcePlanner _planner;
|
||||
private readonly RowLoader _loader;
|
||||
private readonly HotLayer _hot;
|
||||
private readonly IEventStore _events;
|
||||
private readonly ITestStore _tests;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public QueryService(RangeResolver resolver, SourcePlanner planner, RowLoader loader, HotLayer hot, IEventStore events, ITestStore tests, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_resolver = resolver;
|
||||
_planner = planner;
|
||||
_loader = loader;
|
||||
_hot = hot;
|
||||
_events = events;
|
||||
_tests = tests;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public Result<(QueryRange Range, SourcePlan Plan)> Plan(RangeRequest request, string defaultRange)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
Result<QueryRange> resolved = _resolver.Resolve(request, now, defaultRange);
|
||||
if (!resolved.TryGet(out QueryRange? range, out FleetError? error))
|
||||
{
|
||||
return Result<(QueryRange, SourcePlan)>.Fail(error);
|
||||
}
|
||||
|
||||
SourcePlan plan = _planner.Plan(range, now, _hot.Ready);
|
||||
if (plan.StepMs != range.StepMs)
|
||||
{
|
||||
range = range.WithStep(plan.StepMs);
|
||||
}
|
||||
|
||||
return Result<(QueryRange, SourcePlan)>.Ok((range, plan));
|
||||
}
|
||||
|
||||
// ───────────────────────── 单条线路的时序 ─────────────────────────
|
||||
|
||||
public async ValueTask<Result<SeriesResult>> SeriesAsync(LineKey line, RangeRequest request, MetricSelection selection, CancellationToken ct)
|
||||
{
|
||||
// anchor=latest:离线或刚恢复的线路,窗口以它最后一个样本为终点(落在最近 5 秒之内就当作在线,仍以当前时间为终点)
|
||||
if (string.Equals(request.Anchor, "latest", StringComparison.OrdinalIgnoreCase) && request.To is null && request.From is null && request.Range is not null
|
||||
&& RangeResolver.TryParseSpan(request.Range, out long span))
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (await _loader.LatestSampleMsAsync(line.Agent, line.Series, now, ct) is { } latest && latest < now - 5000)
|
||||
{
|
||||
request = request with { Range = null, From = DateTimeOffset.FromUnixTimeMilliseconds(latest - span), To = DateTimeOffset.FromUnixTimeMilliseconds(latest) };
|
||||
}
|
||||
}
|
||||
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "15m");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<SeriesResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
|
||||
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
|
||||
{
|
||||
return Result<SeriesResult>.Fail(error);
|
||||
}
|
||||
|
||||
BucketedRows bucketed = BucketedRows.Create(rows, p.Range);
|
||||
IReadOnlyDictionary<string, double> overlay = selection.WantsScores ? await TestOverlayAsync(line, ct) : new Dictionary<string, double>();
|
||||
|
||||
int n = p.Range.Points;
|
||||
var values = selection.AllIds.ToDictionary(id => id, _ => new double?[n], StringComparer.Ordinal);
|
||||
double widthSeconds = p.Plan.LevelWidthMs / 1000.0;
|
||||
IEnumerable<string> evaluate = selection.WantsScores ? ExportEvaluator.AllExportIds() : selection.ExportIds;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
MetricSet? set = bucketed.Sets[i];
|
||||
if (set is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, double> exported = ExportEvaluator.Evaluate(set, evaluate, bucketed.RowCounts[i] * widthSeconds);
|
||||
foreach (string id in selection.ExportIds)
|
||||
{
|
||||
if (exported.TryGetValue(id, out double v))
|
||||
{
|
||||
values[id][i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.WantsScores)
|
||||
{
|
||||
Dictionary<string, ModelScore> scores = ModelRegistry.ScoreAll(BuildView(exported, overlay));
|
||||
foreach (string model in selection.ScoreModelIds)
|
||||
{
|
||||
values["score." + model][i] = scores[model].Score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, overlay);
|
||||
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
IReadOnlyList<EventRecord> events = await _events.ListAsync(line.Agent, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct);
|
||||
IReadOnlyList<GapInfo> gaps = FindGaps(bucketed, p.Range, nowMs, events);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return Result<SeriesResult>.Ok(new SeriesResult(line, p.Range, p.Plan, bucketed.T, values, summary, gaps, events, versions));
|
||||
}
|
||||
|
||||
/// <summary>这条线路最早的数据(Unix 毫秒);没有数据返回 null。</summary>
|
||||
public ValueTask<long?> EarliestAsync(LineKey line, CancellationToken ct) =>
|
||||
_loader.EarliestSampleMsAsync(line.Agent, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
// ───────────────────────── 首页排行 ─────────────────────────
|
||||
|
||||
public async ValueTask<Result<OverviewResult>> OverviewAsync(IReadOnlyList<LineView> lines, RangeRequest request, MetricSelection selection, CancellationToken ct)
|
||||
{
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "5m");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<OverviewResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>> loaded = await _loader.LoadManyAsync(p.Plan, p.Range, [.. lines.Select(l => l.Key)], ct);
|
||||
if (!loaded.TryGet(out Dictionary<LineKey, IReadOnlyList<SourceRow>>? byLine, out error))
|
||||
{
|
||||
return Result<OverviewResult>.Fail(error);
|
||||
}
|
||||
|
||||
var result = new List<OverviewLine>(lines.Count);
|
||||
foreach (LineView line in lines)
|
||||
{
|
||||
IReadOnlyList<SourceRow> rows = byLine.GetValueOrDefault(line.Key) ?? [];
|
||||
var merged = new MetricSet();
|
||||
int count = 0;
|
||||
foreach (SourceRow row in rows)
|
||||
{
|
||||
if (row.Start >= p.Range.FromMs && row.Start < p.Range.ToMs)
|
||||
{
|
||||
merged.Merge(row.Set);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, double> overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary<string, double>();
|
||||
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, ct);
|
||||
result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null));
|
||||
}
|
||||
|
||||
return Result<OverviewResult>.Ok(new OverviewResult(p.Range, result, p.Plan));
|
||||
}
|
||||
|
||||
// ───────────────────────── 对比、分布、热力图 ─────────────────────────
|
||||
|
||||
public async ValueTask<Result<CompareResult>> CompareAsync(IReadOnlyList<LineKey> lines, string exportId, RangeRequest request, CancellationToken ct)
|
||||
{
|
||||
if (lines.Count == 0 || lines.Count > _options.MaxCompareAgents)
|
||||
{
|
||||
return ValidationError.Fail<CompareResult>("lines", $"between 1 and {_options.MaxCompareAgents} lines are required");
|
||||
}
|
||||
|
||||
if (!MetricRegistry.TryFindExport(exportId, out _, out _))
|
||||
{
|
||||
return Result<CompareResult>.Fail(MonitorErrors.UnknownMetric, $"unknown metric {exportId}");
|
||||
}
|
||||
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "1h");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<CompareResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>> loaded = await _loader.LoadManyAsync(p.Plan, p.Range, lines, ct);
|
||||
if (!loaded.TryGet(out Dictionary<LineKey, IReadOnlyList<SourceRow>>? byLine, out error))
|
||||
{
|
||||
return Result<CompareResult>.Fail(error);
|
||||
}
|
||||
|
||||
var values = new Dictionary<string, double?[]>(StringComparer.Ordinal);
|
||||
var summary = new Dictionary<string, double?>(StringComparer.Ordinal);
|
||||
long[] t = [];
|
||||
double widthSeconds = p.Plan.LevelWidthMs / 1000.0;
|
||||
foreach (LineKey line in lines)
|
||||
{
|
||||
BucketedRows bucketed = BucketedRows.Create(byLine[line], p.Range);
|
||||
t = bucketed.T;
|
||||
double?[] column = new double?[p.Range.Points];
|
||||
for (int i = 0; i < column.Length; i++)
|
||||
{
|
||||
if (bucketed.Sets[i] is { } set && ExportEvaluator.Evaluate(set, [exportId], bucketed.RowCounts[i] * widthSeconds).TryGetValue(exportId, out double v))
|
||||
{
|
||||
column[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
values[line.Id] = column;
|
||||
summary[line.Id] = ExportEvaluator.Evaluate(bucketed.Merge(), [exportId], bucketed.TotalRows * widthSeconds).TryGetValue(exportId, out double total) ? total : null;
|
||||
}
|
||||
|
||||
return Result<CompareResult>.Ok(new CompareResult(t, values, summary, exportId, p.Range, p.Plan));
|
||||
}
|
||||
|
||||
public async ValueTask<Result<DistributionResult>> DistributionAsync(LineKey line, string metricId, RangeRequest request, CancellationToken ct)
|
||||
{
|
||||
MetricDef? def = MetricRegistry.Find(metricId);
|
||||
if (def is null || def.Kind != AccKind.Histogram)
|
||||
{
|
||||
return Result<DistributionResult>.Fail(MonitorErrors.UnknownMetric, $"{metricId} is not a histogram metric");
|
||||
}
|
||||
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "15m");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<DistributionResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
|
||||
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
|
||||
{
|
||||
return Result<DistributionResult>.Fail(error);
|
||||
}
|
||||
|
||||
var merged = new HistogramAcc();
|
||||
foreach (SourceRow row in rows)
|
||||
{
|
||||
if (row.Set.TryGet(metricId, out Accumulator acc) && acc is HistogramAcc h)
|
||||
{
|
||||
merged.Merge(h);
|
||||
}
|
||||
}
|
||||
|
||||
var buckets = new List<DistributionBucket>();
|
||||
if (merged.Count > 0)
|
||||
{
|
||||
Dictionary<int, ulong> counts = merged.Buckets().ToDictionary(b => b.Bucket, b => b.Count);
|
||||
int first = counts.Keys.Min();
|
||||
int last = counts.Keys.Max();
|
||||
for (int b = first; b <= last; b++)
|
||||
{
|
||||
buckets.Add(new DistributionBucket(Math.Round(HistogramAcc.UpperBound(b), 3), counts.GetValueOrDefault(b)));
|
||||
}
|
||||
}
|
||||
|
||||
return Result<DistributionResult>.Ok(new DistributionResult(metricId, def.Exports[0].Unit.ToString().ToLowerInvariant(), buckets, merged.Quantile(0.5), merged.Quantile(0.95), merged.Quantile(0.99), merged.Count));
|
||||
}
|
||||
|
||||
public async ValueTask<Result<HeatmapResult>> HeatmapAsync(LineKey line, string metricId, RangeRequest request, CancellationToken ct)
|
||||
{
|
||||
MetricDef? def = MetricRegistry.Find(metricId);
|
||||
if (def is null || def.Kind != AccKind.Histogram)
|
||||
{
|
||||
return Result<HeatmapResult>.Fail(MonitorErrors.UnknownMetric, $"{metricId} is not a histogram metric");
|
||||
}
|
||||
|
||||
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "6h");
|
||||
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
|
||||
{
|
||||
return Result<HeatmapResult>.Fail(error);
|
||||
}
|
||||
|
||||
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
|
||||
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
|
||||
{
|
||||
return Result<HeatmapResult>.Fail(error);
|
||||
}
|
||||
|
||||
BucketedRows bucketed = BucketedRows.Create(rows, p.Range);
|
||||
var counts = new List<IReadOnlyList<ulong>>(bucketed.T.Length);
|
||||
foreach (MetricSet? set in bucketed.Sets)
|
||||
{
|
||||
ulong[] cells = new ulong[HeatmapEdges.Count];
|
||||
if (set is not null && set.TryGet(metricId, out Accumulator acc) && acc is HistogramAcc h)
|
||||
{
|
||||
foreach ((int bucket, ulong count) in h.Buckets())
|
||||
{
|
||||
double upper = HistogramAcc.UpperBound(bucket);
|
||||
int cell = HeatmapEdges.Count - 1;
|
||||
for (int e = 0; e < HeatmapEdges.Count; e++)
|
||||
{
|
||||
if (upper <= HeatmapEdges[e])
|
||||
{
|
||||
cell = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cells[cell] += count;
|
||||
}
|
||||
}
|
||||
|
||||
counts.Add(cells);
|
||||
}
|
||||
|
||||
return Result<HeatmapResult>.Ok(new HeatmapResult(bucketed.T, HeatmapEdges, counts, p.Range, p.Plan));
|
||||
}
|
||||
|
||||
// ───────────────────────── 汇总与评分 ─────────────────────────
|
||||
|
||||
private SummaryResult Summarize(MetricSet merged, int totalRows, SourcePlan plan, QueryRange range, MetricSelection selection, IReadOnlyDictionary<string, double> overlay)
|
||||
{
|
||||
double durationSeconds = totalRows * plan.LevelWidthMs / 1000.0;
|
||||
IEnumerable<string> evaluate = selection.WantsScores ? ExportEvaluator.AllExportIds() : selection.ExportIds;
|
||||
Dictionary<string, double> exported = ExportEvaluator.Evaluate(merged, evaluate, durationSeconds);
|
||||
|
||||
var values = new Dictionary<string, double?>(StringComparer.Ordinal);
|
||||
foreach (string id in selection.ExportIds)
|
||||
{
|
||||
values[id] = exported.TryGetValue(id, out double v) ? v : null;
|
||||
}
|
||||
|
||||
if (selection.WantsScores)
|
||||
{
|
||||
Dictionary<string, ModelScore> scores = ModelRegistry.ScoreAll(BuildView(exported, overlay));
|
||||
foreach (string model in selection.ScoreModelIds)
|
||||
{
|
||||
values["score." + model] = scores[model].Score;
|
||||
}
|
||||
}
|
||||
|
||||
var samples = new Dictionary<string, ulong>(StringComparer.Ordinal);
|
||||
foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports().Where(e => e.Export.Id.StartsWith("n.", StringComparison.Ordinal)))
|
||||
{
|
||||
if (merged.TryGet(def.Id, out Accumulator acc) && acc is CounterAcc counter && counter.Value > 0)
|
||||
{
|
||||
samples[export.Id["n.".Length..]] = counter.Value;
|
||||
}
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long expected = Math.Max(1, (Math.Min(range.ToMs, Math.Max(now, range.FromMs + plan.LevelWidthMs)) - range.FromMs) / plan.LevelWidthMs);
|
||||
return new SummaryResult(values, samples, Math.Round(Math.Min(1.0, (double)totalRows / expected), 4));
|
||||
}
|
||||
|
||||
private static MetricView BuildView(Dictionary<string, double> exported, IReadOnlyDictionary<string, double> overlay)
|
||||
{
|
||||
// 最近一次测试的结果补充持续指标里没有的值(例如 hy2.* 与 connect.*)
|
||||
var all = new Dictionary<string, double>(exported, StringComparer.Ordinal);
|
||||
foreach ((string id, double value) in overlay)
|
||||
{
|
||||
all.TryAdd(id, value);
|
||||
}
|
||||
|
||||
return new MetricView(all);
|
||||
}
|
||||
|
||||
private async ValueTask<IReadOnlyDictionary<string, double>> TestOverlayAsync(LineKey line, CancellationToken ct)
|
||||
{
|
||||
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day);
|
||||
var overlay = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
var seenPlans = new HashSet<string>();
|
||||
foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, ct))
|
||||
{
|
||||
if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await _tests.GetResultAsync(test.Id, ct) is { } stored)
|
||||
{
|
||||
foreach ((string id, double value) in TestService.ToView(stored).Metrics)
|
||||
{
|
||||
overlay.TryAdd(id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/// <summary>连续 ≥ 2 个空桶视为缺口;原因取该缺口附近最近的事件,没有事件就是 no_data。</summary>
|
||||
private static List<GapInfo> FindGaps(BucketedRows bucketed, QueryRange range, long nowMs, IReadOnlyList<EventRecord> events)
|
||||
{
|
||||
var gaps = new List<GapInfo>();
|
||||
int i = 0;
|
||||
int n = bucketed.Sets.Length;
|
||||
while (i < n)
|
||||
{
|
||||
if (bucketed.Sets[i] is not null || bucketed.T[i] >= nowMs)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int start = i;
|
||||
while (i < n && bucketed.Sets[i] is null && bucketed.T[i] < nowMs)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i - start >= 2)
|
||||
{
|
||||
long from = bucketed.T[start];
|
||||
long to = bucketed.T[i - 1] + range.StepMs;
|
||||
EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "probe_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated");
|
||||
gaps.Add(new GapInfo(from, to, near?.Kind ?? "no_data"));
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/// <summary>从 JSON 文本读取参数(供端点之外的调用方使用)。</summary>
|
||||
public static Dictionary<string, JsonElement> ParseParams(string json) =>
|
||||
JsonSerializer.Deserialize(json, TestJson.Default.ParamMap) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <param name="Anchor"><c>now</c>(默认)或 <c>latest</c>:相对时间段(<c>range=</c>)以当前时间还是以这条线路最后一个样本为终点。后者让离线的线路显示它最后一段数据,而不是空图。</param>
|
||||
public sealed record RangeRequest(string? Range, DateTimeOffset? From, DateTimeOffset? To, string? Step, string? TimeZone, string? Anchor = null);
|
||||
|
||||
/// <summary>对齐之后的查询时间段:<c>[FromMs, ToMs)</c>,区间内每个桶宽 <see cref="StepMs"/>。</summary>
|
||||
public sealed record QueryRange(long FromMs, long ToMs, long StepMs, bool IsRelative, long RequestedFromMs, long RequestedToMs)
|
||||
{
|
||||
public int Points => (int)((ToMs - FromMs) / StepMs);
|
||||
|
||||
public bool FirstPartial => RequestedFromMs != FromMs;
|
||||
|
||||
public bool LastPartial => RequestedToMs != ToMs;
|
||||
|
||||
public QueryRange WithStep(long step) => this with { StepMs = step, FromMs = Steps.FloorTo(RequestedFromMs, step), ToMs = Steps.CeilTo(RequestedToMs, step) };
|
||||
}
|
||||
|
||||
/// <summary>range 或 from/to 与 step → 对齐后的桶序列。</summary>
|
||||
public sealed partial class RangeResolver
|
||||
{
|
||||
private const long MaxSpanMs = 400 * Steps.Day;
|
||||
|
||||
private readonly BackendOptions _options;
|
||||
|
||||
public RangeResolver(BackendOptions options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Result<QueryRange> Resolve(RangeRequest request, long nowMs, string defaultRange = "5m")
|
||||
{
|
||||
// 目前所有分桶都按 UTC 对齐;其他时区(例如“按天”从本地零点切分)还没有实现,明确拒绝而不是悄悄忽略
|
||||
if (!string.IsNullOrEmpty(request.TimeZone) && !request.TimeZone.Equals("UTC", StringComparison.OrdinalIgnoreCase) && request.TimeZone != "Z")
|
||||
{
|
||||
return ValidationError.Fail<QueryRange>("tz", "only UTC is supported at the moment");
|
||||
}
|
||||
|
||||
if (request.Anchor is not null && !request.Anchor.Equals("now", StringComparison.OrdinalIgnoreCase) && !request.Anchor.Equals("latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidationError.Fail<QueryRange>("anchor", "must be now or latest");
|
||||
}
|
||||
|
||||
if (request.Range is not null && (request.From is not null || request.To is not null))
|
||||
{
|
||||
return ValidationError.Fail<QueryRange>("range", "is mutually exclusive with from and to");
|
||||
}
|
||||
|
||||
long from;
|
||||
long to;
|
||||
bool relative;
|
||||
if (request.From is not null || request.To is not null)
|
||||
{
|
||||
to = request.To?.ToUnixTimeMilliseconds() ?? nowMs;
|
||||
from = request.From?.ToUnixTimeMilliseconds() ?? (to - Steps.Hour);
|
||||
relative = request.To is null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryParseSpan(request.Range ?? defaultRange, out long span))
|
||||
{
|
||||
return Result<QueryRange>.Fail(MonitorErrors.InvalidRange, "range must look like 5m, 6h or 7d");
|
||||
}
|
||||
|
||||
to = nowMs;
|
||||
from = nowMs - span;
|
||||
relative = true;
|
||||
}
|
||||
|
||||
if (from >= to)
|
||||
{
|
||||
return Result<QueryRange>.Fail(MonitorErrors.InvalidRange, "from must be earlier than to");
|
||||
}
|
||||
|
||||
if (to - from > MaxSpanMs)
|
||||
{
|
||||
return Result<QueryRange>.Fail(MonitorErrors.RangeTooLarge, "the range is longer than 400 days");
|
||||
}
|
||||
|
||||
long step;
|
||||
if (request.Step is null or "auto")
|
||||
{
|
||||
step = Steps.Auto(to - from);
|
||||
}
|
||||
else if (!Steps.TryParse(request.Step, out step))
|
||||
{
|
||||
return Result<QueryRange>.Fail(MonitorErrors.InvalidStep, "step must be one of: " + string.Join(", ", Steps.Common.Select(Steps.Format)));
|
||||
}
|
||||
|
||||
var range = new QueryRange(Steps.FloorTo(from, step), Steps.CeilTo(to, step), step, relative, from, to);
|
||||
if (range.Points > _options.MaxPointsPerSeries)
|
||||
{
|
||||
long suggested = Steps.Common.FirstOrDefault(s => (Steps.CeilTo(to, s) - Steps.FloorTo(from, s)) / s <= _options.MaxPointsPerSeries, Steps.Day);
|
||||
return Result<QueryRange>.Fail(MonitorErrors.RangeTooLarge, $"{range.Points} points exceed the limit of {_options.MaxPointsPerSeries}; use step={Steps.Format(suggested)} or a shorter range");
|
||||
}
|
||||
|
||||
return Result<QueryRange>.Ok(range);
|
||||
}
|
||||
|
||||
public static bool TryParseSpan(string text, out long ms)
|
||||
{
|
||||
ms = 0;
|
||||
Match m = SpanPattern().Match(text);
|
||||
if (!m.Success || !long.TryParse(m.Groups[1].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out long n) || n <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long unit = m.Groups[2].Value switch
|
||||
{
|
||||
"s" => Steps.Second,
|
||||
"m" => Steps.Minute,
|
||||
"h" => Steps.Hour,
|
||||
_ => Steps.Day,
|
||||
};
|
||||
if (n > MaxSpanMs / unit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ms = n * unit;
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(\d{1,6})([smhd])$")]
|
||||
private static partial Regex SpanPattern();
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public readonly record struct SourceRow(long Start, MetricSet Set, bool Partial);
|
||||
|
||||
/// <summary>从原始块现算的查询全局最多同时 2 个。</summary>
|
||||
public sealed class RawQueryGate : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _slots = new(2, 2);
|
||||
|
||||
public bool TryEnter() => _slots.Wait(0);
|
||||
|
||||
public void Exit() => _slots.Release();
|
||||
|
||||
public void Dispose() => _slots.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>按数据源计划读取一条序列的累加器行(热层、10s/1m/5m/1h 层,或从原始块现算)。</summary>
|
||||
public sealed class RowLoader
|
||||
{
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly HotLayer _hot;
|
||||
private readonly WindowComputer _computer;
|
||||
private readonly RawQueryGate _gate;
|
||||
|
||||
public RowLoader(IMetricStore metrics, HotLayer hot, WindowComputer computer, RawQueryGate gate)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_hot = hot;
|
||||
_computer = computer;
|
||||
_gate = gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这条序列最后一个样本的时刻:先看热层(1 秒精度,保留 1 小时),没有再看 1 分钟层最近 7 天(精度是窗口宽度)。没有任何数据返回 null。
|
||||
/// </summary>
|
||||
public async ValueTask<long?> LatestSampleMsAsync(NodeId agent, string series, long nowMs, CancellationToken ct)
|
||||
{
|
||||
long? hot = _hot.GetRange(agent, series, nowMs - Steps.Hour, nowMs + 10_000).Select(r => (long?)r.Key).Max();
|
||||
if (hot is { } at)
|
||||
{
|
||||
return at + 1000;
|
||||
}
|
||||
|
||||
IReadOnlyList<MetricRow> rows = await _metrics.ReadAsync(MetricLevel.M1, agent, series, nowMs - (7 * Steps.Day), nowMs + 60_000, ct);
|
||||
return rows.Count == 0 ? null : rows.Max(r => r.WindowStart) + 60_000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这条序列最早的数据在什么时候:各层(10 秒、1 分钟、5 分钟、1 小时)里最早的窗口,加上热层里还没落到持久层的部分。
|
||||
/// 展示页据此只提供“有数据”的时间范围。没有任何数据返回 null。
|
||||
/// </summary>
|
||||
public async ValueTask<long?> EarliestSampleMsAsync(NodeId agent, string series, long nowMs, CancellationToken ct)
|
||||
{
|
||||
// 从粗到细:粗的层窗口很宽(最宽 1 小时),起点会比真实的第一个样本早;
|
||||
// 如果更细的层的起点落在粗层第一个窗口里面,就用更细的(更准确);落在窗口之外说明更早的数据只剩粗层里有。
|
||||
long? earliest = null;
|
||||
long width = 0;
|
||||
foreach (MetricLevel level in new[] { MetricLevel.H1, MetricLevel.M5, MetricLevel.M1, MetricLevel.S10 })
|
||||
{
|
||||
if (await _metrics.EarliestAsync(level, agent, series, ct) is not { } at)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (earliest is null || at < earliest.Value + width)
|
||||
{
|
||||
earliest = earliest is null ? at : Math.Min(earliest.Value, at);
|
||||
}
|
||||
|
||||
width = level.WidthMs();
|
||||
}
|
||||
|
||||
long? hot = _hot.GetRange(agent, series, nowMs - Steps.Hour, nowMs + 10_000).Select(r => (long?)r.Key).Min();
|
||||
if (hot is { } h && (earliest is null || h < earliest.Value + width))
|
||||
{
|
||||
earliest = earliest is null ? h : Math.Min(earliest.Value, h);
|
||||
}
|
||||
|
||||
return earliest;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<IReadOnlyList<SourceRow>>> LoadAsync(SourcePlan plan, QueryRange range, NodeId agent, string series, CancellationToken ct)
|
||||
{
|
||||
switch (plan.Source)
|
||||
{
|
||||
case DataSource.Hot:
|
||||
return Result<IReadOnlyList<SourceRow>>.Ok([.. _hot.GetRange(agent, series, range.FromMs, range.ToMs).Select(r => new SourceRow(r.Key, r.Value, false))]);
|
||||
case DataSource.Raw:
|
||||
if (!_gate.TryEnter())
|
||||
{
|
||||
return Result<IReadOnlyList<SourceRow>>.Fail(MonitorErrors.TooManyRequests, "too many queries are reading raw data; retry shortly or use a coarser step");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WindowComputer.Computed computed = await _computer.ComputeAsync(agent, series, range.FromMs, range.ToMs, plan.StepMs, null, ct);
|
||||
return Result<IReadOnlyList<SourceRow>>.Ok([.. computed.Buckets.Select(b => new SourceRow(b.Key, b.Value, computed.AnyPartial))]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Exit();
|
||||
}
|
||||
|
||||
default:
|
||||
IReadOnlyList<MetricRow> rows = await _metrics.ReadAsync(ToLevel(plan.Source), agent, series, range.FromMs, range.ToMs, ct);
|
||||
return Result<IReadOnlyList<SourceRow>>.Ok(Decode(rows));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>多条线路一起读取(首页排行)。热层与原始块现算逐条读取。</summary>
|
||||
public async ValueTask<Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>> LoadManyAsync(
|
||||
SourcePlan plan, QueryRange range, IReadOnlyCollection<LineKey> lines, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<LineKey, IReadOnlyList<SourceRow>>();
|
||||
if (plan.Source is DataSource.Hot or DataSource.Raw)
|
||||
{
|
||||
foreach (LineKey line in lines)
|
||||
{
|
||||
Result<IReadOnlyList<SourceRow>> one = await LoadAsync(plan, range, line.Agent, line.Series, ct);
|
||||
if (!one.TryGet(out IReadOnlyList<SourceRow>? rows, out FleetError? error))
|
||||
{
|
||||
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Fail(error);
|
||||
}
|
||||
|
||||
result[line] = rows;
|
||||
}
|
||||
|
||||
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Ok(result);
|
||||
}
|
||||
|
||||
// 每个 Probe 一个序列:按序列分组,每组一次查询
|
||||
foreach (IGrouping<string, LineKey> bySeries in lines.GroupBy(l => l.Series))
|
||||
{
|
||||
NodeId[] agents = [.. bySeries.Select(l => l.Agent).Distinct()];
|
||||
IReadOnlyList<MetricRow> all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct);
|
||||
foreach (IGrouping<NodeId, MetricRow> group in all.GroupBy(r => r.AgentId))
|
||||
{
|
||||
result[new LineKey(group.Key, new ProbeId(bySeries.Key[RawChunkHeader.LinePrefix.Length..]))] = Decode([.. group]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (LineKey line in lines)
|
||||
{
|
||||
result.TryAdd(line, []);
|
||||
}
|
||||
|
||||
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Ok(result);
|
||||
}
|
||||
|
||||
private static MetricLevel ToLevel(DataSource source) => source switch
|
||||
{
|
||||
DataSource.S10 => MetricLevel.S10,
|
||||
DataSource.M1 => MetricLevel.M1,
|
||||
DataSource.M5 => MetricLevel.M5,
|
||||
_ => MetricLevel.H1,
|
||||
};
|
||||
|
||||
private static List<SourceRow> Decode(IReadOnlyList<MetricRow> rows)
|
||||
{
|
||||
var result = new List<SourceRow>(rows.Count);
|
||||
foreach (MetricRow row in rows)
|
||||
{
|
||||
// 损坏的行跳过(体现为缺口),不让一行坏数据拖垮整个查询
|
||||
if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? set, out _))
|
||||
{
|
||||
result.Add(new SourceRow(row.WindowStart, set, row.Partial));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public enum DataSource
|
||||
{
|
||||
Hot,
|
||||
S10,
|
||||
M1,
|
||||
M5,
|
||||
H1,
|
||||
Raw,
|
||||
}
|
||||
|
||||
public sealed record SourcePlan(DataSource Source, long LevelWidthMs, long StepMs, bool Degraded)
|
||||
{
|
||||
public string Name => Source switch
|
||||
{
|
||||
DataSource.Hot => "hot",
|
||||
DataSource.S10 => "10s",
|
||||
DataSource.M1 => "1m",
|
||||
DataSource.M5 => "5m",
|
||||
DataSource.H1 => "1h",
|
||||
_ => "raw",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选择数据源:在“覆盖该时间段”并且“粒度能整除步长”的各层中选最粗的一层,再把累加器按步长现场合并。
|
||||
/// 没有这样的层时:区间 ≤ 15 分钟且步长 < 10 s,从原始块现算;否则降级为可用的最细层并标记 degraded。
|
||||
/// </summary>
|
||||
public sealed class SourcePlanner
|
||||
{
|
||||
public const long RawComputeMaxSpanMs = 15 * Steps.Minute;
|
||||
|
||||
private readonly BackendOptions _options;
|
||||
|
||||
public SourcePlanner(BackendOptions options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public SourcePlan Plan(QueryRange range, long nowMs, bool hotReady)
|
||||
{
|
||||
long from = range.RequestedFromMs;
|
||||
long step = range.StepMs;
|
||||
|
||||
// 由粗到细
|
||||
(DataSource Source, long Width, bool Available)[] levels =
|
||||
[
|
||||
(DataSource.H1, Steps.Hour, true),
|
||||
(DataSource.M5, 5 * Steps.Minute, from >= nowMs - (long)_options.Metric5mRetentionDays * Steps.Day),
|
||||
(DataSource.M1, Steps.Minute, from >= nowMs - (long)_options.Metric1mRetentionDays * Steps.Day),
|
||||
(DataSource.S10, 10 * Steps.Second, from >= nowMs - (long)_options.Metric10sRetentionHours * Steps.Hour),
|
||||
(DataSource.Hot, Steps.Second, hotReady && from >= nowMs - Steps.Hour),
|
||||
];
|
||||
|
||||
foreach ((DataSource source, long width, bool available) in levels)
|
||||
{
|
||||
if (available && step % width == 0)
|
||||
{
|
||||
return new SourcePlan(source, width, step, false);
|
||||
}
|
||||
}
|
||||
|
||||
bool rawAvailable = from >= nowMs - (long)_options.RawRetentionDays * Steps.Day;
|
||||
if (rawAvailable && step < 10 * Steps.Second && range.ToMs - range.FromMs <= RawComputeMaxSpanMs)
|
||||
{
|
||||
return new SourcePlan(DataSource.Raw, step, step, false);
|
||||
}
|
||||
|
||||
// 降级:可用的最细层,步长上调到该层宽度的整数倍
|
||||
foreach ((DataSource source, long width, bool available) in levels.Reverse())
|
||||
{
|
||||
if (available)
|
||||
{
|
||||
return new SourcePlan(source, width, Steps.Snap(step, width), true);
|
||||
}
|
||||
}
|
||||
|
||||
return new SourcePlan(DataSource.H1, Steps.Hour, Steps.Snap(step, Steps.Hour), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>常用步长:1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d。</summary>
|
||||
public static class Steps
|
||||
{
|
||||
public const long Second = 1000;
|
||||
public const long Minute = 60 * Second;
|
||||
public const long Hour = 60 * Minute;
|
||||
public const long Day = 24 * Hour;
|
||||
|
||||
/// <summary>每条曲线最多约 720 个点。</summary>
|
||||
public const int TargetPoints = 720;
|
||||
|
||||
public static readonly IReadOnlyList<long> Common =
|
||||
[
|
||||
1 * Second, 2 * Second, 5 * Second, 10 * Second, 15 * Second, 30 * Second,
|
||||
1 * Minute, 2 * Minute, 5 * Minute, 10 * Minute, 15 * Minute, 30 * Minute,
|
||||
1 * Hour, 2 * Hour, 3 * Hour, 6 * Hour, 12 * Hour, 1 * Day,
|
||||
];
|
||||
|
||||
/// <summary>步长 = 常用步长中 ≥ 区间长度 / 720 的最小值。</summary>
|
||||
public static long Auto(long spanMs)
|
||||
{
|
||||
long wanted = (spanMs + TargetPoints - 1) / TargetPoints;
|
||||
foreach (long step in Common)
|
||||
{
|
||||
if (step >= wanted)
|
||||
{
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
return Common[^1];
|
||||
}
|
||||
|
||||
public static bool TryParse(string text, out long ms)
|
||||
{
|
||||
foreach (long step in Common)
|
||||
{
|
||||
if (string.Equals(Format(step), text, StringComparison.Ordinal))
|
||||
{
|
||||
ms = step;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ms = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string Format(long ms)
|
||||
{
|
||||
if (ms % Day == 0)
|
||||
{
|
||||
return (ms / Day).ToString(CultureInfo.InvariantCulture) + "d";
|
||||
}
|
||||
|
||||
if (ms % Hour == 0)
|
||||
{
|
||||
return (ms / Hour).ToString(CultureInfo.InvariantCulture) + "h";
|
||||
}
|
||||
|
||||
if (ms % Minute == 0)
|
||||
{
|
||||
return (ms / Minute).ToString(CultureInfo.InvariantCulture) + "m";
|
||||
}
|
||||
|
||||
return (ms / Second).ToString(CultureInfo.InvariantCulture) + "s";
|
||||
}
|
||||
|
||||
/// <summary>不小于 <paramref name="step"/> 且是 <paramref name="width"/> 整数倍的最小常用步长。</summary>
|
||||
public static long Snap(long step, long width)
|
||||
{
|
||||
foreach (long candidate in Common)
|
||||
{
|
||||
if (candidate >= step && candidate % width == 0)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
public static long FloorTo(long ms, long step) => Math.DivRem(ms, step, out long rem) * step - (rem < 0 ? step : 0);
|
||||
|
||||
public static long CeilTo(long ms, long step) => FloorTo(ms + step - 1, step);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>保留清理:原始段文件(持续任务与按需测试保留期不同)、各层指标、事件。保留期来自 settings,可以在线修改。</summary>
|
||||
public sealed partial class RetentionService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan s_interval = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly IRawStore _raw;
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly IEventStore _events;
|
||||
private readonly IAuthStore _auth;
|
||||
private readonly IHostStore _hosts;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly LoginRateLimiter _limiter;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<RetentionService> _logger;
|
||||
|
||||
public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger<RetentionService> logger)
|
||||
{
|
||||
_raw = raw;
|
||||
_metrics = metrics;
|
||||
_events = events;
|
||||
_hosts = hosts;
|
||||
_auth = auth;
|
||||
_settings = settings;
|
||||
_limiter = limiter;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
await Task.Delay(s_interval, _time, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 后台循环不能因为一次失败而退出
|
||||
LogFailed(ex);
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), _time, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>执行一轮清理(也供测试直接调用)。</summary>
|
||||
public async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = _time.GetUtcNow();
|
||||
long nowMs = now.ToUnixTimeMilliseconds();
|
||||
DateOnly today = DateOnly.FromDateTime(now.UtcDateTime);
|
||||
int rawDays = await _settings.GetIntAsync(SettingsService.RawDays, ct);
|
||||
int testDays = await _settings.GetIntAsync(SettingsService.TestRawDays, ct);
|
||||
|
||||
await _raw.DeleteExpiredAsync(today.AddDays(-rawDays), today.AddDays(-testDays), ct);
|
||||
await _metrics.DeleteOlderThanAsync(MetricLevel.S10, nowMs - (await _settings.GetIntAsync(SettingsService.Metric10sHours, ct) * Steps.Hour), ct);
|
||||
await _metrics.DeleteOlderThanAsync(MetricLevel.M1, nowMs - (await _settings.GetIntAsync(SettingsService.Metric1mDays, ct) * Steps.Day), ct);
|
||||
await _metrics.DeleteOlderThanAsync(MetricLevel.M5, nowMs - (await _settings.GetIntAsync(SettingsService.Metric5mDays, ct) * Steps.Day), ct);
|
||||
await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct);
|
||||
await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct);
|
||||
await _auth.DeleteExpiredSessionsAsync(nowMs, ct);
|
||||
_limiter.Prune();
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "retention pass failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
|
||||
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
||||
/// </summary>
|
||||
public sealed class StartupTasks : IHostedService
|
||||
{
|
||||
private readonly IRawStore _raw;
|
||||
private readonly AuthService _auth;
|
||||
private readonly TaskService _tasks;
|
||||
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks)
|
||||
{
|
||||
_raw = raw;
|
||||
_auth = auth;
|
||||
_tasks = tasks;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _raw.RecoverAsync(cancellationToken);
|
||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record EffectiveTask(
|
||||
TaskId Id,
|
||||
string Scope,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement> Params,
|
||||
string Schedule,
|
||||
ParamValues Resolved);
|
||||
|
||||
/// <summary>
|
||||
/// 有效任务集:线路任务模板 + 针对单个 Agent 的覆盖 + 该 Agent 的目标任务。
|
||||
/// 参数经 ParamSpec 校验并补全默认值,Backend 和节点使用同一套规则。
|
||||
/// </summary>
|
||||
public static class EffectiveTasks
|
||||
{
|
||||
public const string LineScope = "line";
|
||||
public const string TargetScope = "target";
|
||||
|
||||
public static IReadOnlyList<EffectiveTask> Compute(IReadOnlyList<TaskRecord> all, AgentRecord agent, bool filterByAgentKinds)
|
||||
{
|
||||
var result = new List<EffectiveTask>();
|
||||
var overrides = new Dictionary<TaskId, TaskRecord>();
|
||||
foreach (TaskRecord task in all)
|
||||
{
|
||||
if (task.AgentId == agent.Id && task.BaseTaskId is { } baseId)
|
||||
{
|
||||
overrides[baseId] = task;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TaskRecord task in all.OrderBy(t => t.Id.Value, StringComparer.Ordinal))
|
||||
{
|
||||
if (task.Scope == LineScope && task.AgentId is null)
|
||||
{
|
||||
// 模板:有覆盖时以覆盖为准(覆盖可以停用或改参数)
|
||||
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);
|
||||
}
|
||||
}
|
||||
else if (task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, null, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, JsonElement> Merge(IReadOnlyDictionary<string, JsonElement> baseParams, IReadOnlyDictionary<string, JsonElement> over)
|
||||
{
|
||||
var merged = new Dictionary<string, JsonElement>(baseParams);
|
||||
foreach ((string key, JsonElement value) in over)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
List<EffectiveTask> result,
|
||||
TaskId id,
|
||||
string scope,
|
||||
string kind,
|
||||
string? target,
|
||||
IReadOnlyDictionary<string, JsonElement> parameters,
|
||||
string schedule,
|
||||
AgentRecord agent,
|
||||
bool filterByAgentKinds)
|
||||
{
|
||||
KindSpec? spec = Kinds.Find(kind);
|
||||
if (spec is null || (filterByAgentKinds && agent.Kinds.Count > 0 && !agent.Kinds.Contains(kind)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(new EffectiveTask(id, scope, kind, target, resolved.Values!.Values, schedule, resolved.Values));
|
||||
}
|
||||
|
||||
/// <summary>该 Agent 持续线路任务的总带宽(bit/s)。</summary>
|
||||
public static long ContinuousLineBudget(IReadOnlyList<EffectiveTask> tasks)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (EffectiveTask task in tasks)
|
||||
{
|
||||
if (task.Scope == LineScope && task.Schedule == TaskSchedule.Continuous)
|
||||
{
|
||||
total += Kinds.Find(task.Kind)!.BudgetBitsPerSecond(task.Resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record DesiredTaskSet(long Version, TaskSet Document);
|
||||
|
||||
/// <summary>
|
||||
/// 为每个 Agent 计算期望状态(<see cref="TaskSet"/>):有效任务集 + 允许连接的 Probe 公钥。
|
||||
/// 只有内容真的变了才递增版本号,所以断线重连后 Agent 收到的永远是当前版本的完整快照。
|
||||
/// </summary>
|
||||
public sealed class TaskPlanner : IDisposable
|
||||
{
|
||||
public const string ProbeAgentsVersionKey = "probe_agents_version";
|
||||
|
||||
private readonly ITaskStore _tasks;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>pending、disabled 或不存在的 Agent 返回 null(不下发状态)。</summary>
|
||||
public async ValueTask<DesiredTaskSet?> GetDesiredAsync(NodeId agentId, CancellationToken ct)
|
||||
{
|
||||
AgentRecord? agent = await _agents.GetAsync(agentId, ct);
|
||||
if (agent is null || agent.Status != NodeStatus.Active)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<TaskRecord> all = await _tasks.ListAsync(ct);
|
||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||
long listVersion = long.TryParse(await _settings.GetAsync(ProbeAgentsVersionKey, ct), out long v) ? v : 0;
|
||||
|
||||
var document = new TaskSet { ProbeAgentsVersion = listVersion };
|
||||
foreach (EffectiveTask task in EffectiveTasks.Compute(all, agent, filterByAgentKinds: true))
|
||||
{
|
||||
document.Tasks.Add(new TaskSpec
|
||||
{
|
||||
Id = task.Id,
|
||||
Scope = task.Scope,
|
||||
Kind = task.Kind,
|
||||
Target = task.Target,
|
||||
Params = new Dictionary<string, JsonElement>(task.Params),
|
||||
Schedule = task.Schedule,
|
||||
Enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null))
|
||||
{
|
||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) });
|
||||
}
|
||||
|
||||
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MonitorJsonContext.Default.TaskSet)));
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// 重新读取,避免并发的两次计算各自递增
|
||||
AgentRecord current = await _agents.GetAsync(agentId, ct) ?? agent;
|
||||
string? storedHash = await _agents.GetTaskHashAsync(agentId, ct);
|
||||
long version = current.TaskVersion;
|
||||
if (storedHash != hash)
|
||||
{
|
||||
version++;
|
||||
await _agents.SetTaskVersionAsync(agentId, version, hash, ct);
|
||||
}
|
||||
|
||||
document.Version = version;
|
||||
return new DesiredTaskSet(version, document);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
public ValueTask<long> BumpProbeAgentsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(ProbeAgentsVersionKey, ct);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record TaskInput(
|
||||
string Scope,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement>? Params,
|
||||
string? Schedule,
|
||||
bool? Enabled,
|
||||
NodeId? AgentId,
|
||||
TaskId? BaseTaskId);
|
||||
|
||||
/// <summary>可以修改的字段(merge-patch:为 null 的字段不改)。</summary>
|
||||
public sealed record TaskPatch(IReadOnlyDictionary<string, JsonElement>? Params, string? Schedule, bool? Enabled, string? Target);
|
||||
|
||||
public sealed class TaskService
|
||||
{
|
||||
private readonly ITaskStore _tasks;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TaskService(ITaskStore tasks, IAgentStore agents, ISettingsStore settings, IAgentGateway gateway, TimeProvider time)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_agents = agents;
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<TaskRecord>> ListAsync(CancellationToken ct) => _tasks.ListAsync(ct);
|
||||
|
||||
public ValueTask<TaskRecord?> GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct);
|
||||
|
||||
/// <summary>首次启动时创建默认的线路任务模板(udp_echo、tcp_echo 持续运行),这样新 Agent 注册后自动获得任务。</summary>
|
||||
public async ValueTask SeedDefaultsAsync(CancellationToken ct)
|
||||
{
|
||||
if (await _settings.GetAsync("tasks.seeded", ct) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
|
||||
{
|
||||
await _tasks.CreateAsync(
|
||||
new TaskRecord(new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)), EffectiveTasks.LineScope, null, null, kind.Id, null, new Dictionary<string, JsonElement>(), TaskSchedule.Continuous, true, now, 0),
|
||||
ct);
|
||||
}
|
||||
|
||||
await _settings.SetAsync("tasks.seeded", "1", ct);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TaskRecord>> CreateAsync(TaskInput input, CancellationToken ct)
|
||||
{
|
||||
Result<ValidatedTask> validated = await ValidateAsync(input.Scope, input.Kind, input.Target, input.Params, input.Schedule ?? TaskSchedule.Continuous, input.AgentId, input.BaseTaskId, ct);
|
||||
if (!validated.TryGet(out ValidatedTask? v, out FleetError? error))
|
||||
{
|
||||
return Result<TaskRecord>.Fail(error);
|
||||
}
|
||||
|
||||
var record = new TaskRecord(
|
||||
new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)),
|
||||
input.Scope,
|
||||
input.AgentId,
|
||||
input.BaseTaskId,
|
||||
input.Kind,
|
||||
v.Target,
|
||||
input.Params ?? new Dictionary<string, JsonElement>(),
|
||||
input.Schedule ?? TaskSchedule.Continuous,
|
||||
input.Enabled ?? true,
|
||||
_time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
0);
|
||||
|
||||
IReadOnlyList<TaskRecord> after = [.. await _tasks.ListAsync(ct), record];
|
||||
Result<bool> budget = await CheckBudgetAsync(after, record.AgentId, ct);
|
||||
if (!budget.IsSuccess)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(budget.Error);
|
||||
}
|
||||
|
||||
await _tasks.CreateAsync(record, ct);
|
||||
Notify(record);
|
||||
return Result<TaskRecord>.Ok(record);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TaskRecord>> UpdateAsync(TaskId id, TaskPatch patch, long? expectedRowVersion, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? current = await _tasks.GetAsync(id, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(MonitorErrors.TaskNotFound, "task not found");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, JsonElement> parameters = patch.Params ?? current.Params;
|
||||
string schedule = patch.Schedule ?? current.Schedule;
|
||||
string? target = patch.Target ?? current.Target;
|
||||
Result<ValidatedTask> validated = await ValidateAsync(current.Scope, current.Kind, target, parameters, schedule, current.AgentId, current.BaseTaskId, ct);
|
||||
if (!validated.TryGet(out ValidatedTask? v, out FleetError? error))
|
||||
{
|
||||
return Result<TaskRecord>.Fail(error);
|
||||
}
|
||||
|
||||
TaskRecord updated = current with
|
||||
{
|
||||
Params = parameters,
|
||||
Schedule = schedule,
|
||||
Target = v.Target,
|
||||
Enabled = patch.Enabled ?? current.Enabled,
|
||||
UpdatedAtMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
IReadOnlyList<TaskRecord> after = [.. (await _tasks.ListAsync(ct)).Where(t => t.Id != id), updated];
|
||||
Result<bool> budget = await CheckBudgetAsync(after, updated.AgentId, ct);
|
||||
if (!budget.IsSuccess)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(budget.Error);
|
||||
}
|
||||
|
||||
Result<TaskRecord> saved = await _tasks.UpdateAsync(updated, expectedRowVersion, ct);
|
||||
if (saved.IsSuccess)
|
||||
{
|
||||
Notify(saved.Value);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async ValueTask DeleteAsync(TaskId id, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? current = await _tasks.GetAsync(id, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _tasks.DeleteAsync(id, ct);
|
||||
Notify(current);
|
||||
}
|
||||
|
||||
private void Notify(TaskRecord task)
|
||||
{
|
||||
if (task.AgentId is { } agent)
|
||||
{
|
||||
_gateway.NotifyTaskSetChanged(agent);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ValidatedTask(string? Target);
|
||||
|
||||
private async ValueTask<Result<ValidatedTask>> ValidateAsync(
|
||||
string scope,
|
||||
string kind,
|
||||
string? target,
|
||||
IReadOnlyDictionary<string, JsonElement>? parameters,
|
||||
string schedule,
|
||||
NodeId? agentId,
|
||||
TaskId? baseTaskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (scope is not (EffectiveTasks.LineScope or EffectiveTasks.TargetScope))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("scope", "must be line or target");
|
||||
}
|
||||
|
||||
KindSpec? spec = Kinds.Find(kind);
|
||||
if (spec is null)
|
||||
{
|
||||
return Result<ValidatedTask>.Fail(MonitorErrors.UnknownKind, $"unknown kind {kind}");
|
||||
}
|
||||
|
||||
Scope wanted = scope == EffectiveTasks.LineScope ? Scope.Line : Scope.Target;
|
||||
if (!spec.Scopes.HasFlag(wanted))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("kind", $"{kind} cannot be used for {scope} tasks");
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>(resolved.Errors.ToDictionary(e => "params." + e.Key, e => e.Value));
|
||||
}
|
||||
|
||||
Result<int?> scheduleResult = TaskSchedule.Validate(schedule);
|
||||
if (!scheduleResult.IsSuccess)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("schedule", scheduleResult.Error.Message);
|
||||
}
|
||||
|
||||
if (schedule == TaskSchedule.Once)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("schedule", "once tasks are created through the tests API");
|
||||
}
|
||||
|
||||
if (agentId is { } id && await _agents.GetAsync(id, ct) is null)
|
||||
{
|
||||
return Result<ValidatedTask>.Fail(MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (scope == EffectiveTasks.TargetScope)
|
||||
{
|
||||
if (agentId is null || !TryNormalizeTarget(target, out string? normalized))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port");
|
||||
}
|
||||
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(normalized));
|
||||
}
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", "line tasks have no target");
|
||||
}
|
||||
|
||||
if (baseTaskId is { } baseId)
|
||||
{
|
||||
TaskRecord? template = await _tasks.GetAsync(baseId, ct);
|
||||
if (agentId is null || template is null || template.AgentId is not null || template.Scope != EffectiveTasks.LineScope)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("base_task_id", "must reference a line template and requires agent_id");
|
||||
}
|
||||
|
||||
if (template.Kind != kind)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("kind", "an override must use the same kind as its template");
|
||||
}
|
||||
}
|
||||
else if (agentId is not null)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("agent_id", "per-agent line tasks must be overrides (base_task_id)");
|
||||
}
|
||||
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(null));
|
||||
}
|
||||
|
||||
/// <summary>每条线路持续任务的总带宽上限;对所有受影响的 Agent(模板变更时是所有已激活的 Agent)检查。</summary>
|
||||
private async ValueTask<Result<bool>> CheckBudgetAsync(IReadOnlyList<TaskRecord> after, NodeId? agentId, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
IEnumerable<AgentRecord> affected = agentId is { } id
|
||||
? agents.Where(a => a.Id == id)
|
||||
: agents.Where(a => a.Status == NodeStatus.Active);
|
||||
var checkedAny = false;
|
||||
foreach (AgentRecord agent in affected)
|
||||
{
|
||||
checkedAny = true;
|
||||
long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, agent with { Kinds = [] }, filterByAgentKinds: false));
|
||||
if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.BudgetExceeded, $"continuous tasks for {agent.Name} would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkedAny && agentId is null)
|
||||
{
|
||||
// 还没有 Agent:用一个虚拟 Agent 检查模板本身
|
||||
var virtualAgent = new AgentRecord(new NodeId("agt_virtual"), "virtual", NodeStatus.Active, [], [], "", 0, [], [], true, [], null, null, 0, 0, null, 0, null, 0);
|
||||
long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, virtualAgent, filterByAgentKinds: false));
|
||||
if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.BudgetExceeded, $"continuous line templates would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record PhaseSpec(string Kind, IReadOnlyDictionary<string, JsonElement> Overrides, int DurationSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Probe、Measurement)不认识它。
|
||||
/// 测试方案:多个阶段的 once 任务组合(例如 hy2_ladder 在 10、25、50、100 Mbps 下各跑 10 秒)。</summary>
|
||||
public sealed record PlanSpec(string Id, string Title, string Description, IReadOnlyList<PhaseSpec> Phases);
|
||||
|
||||
public static class Plans
|
||||
{
|
||||
private static JsonElement N(double value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.Double);
|
||||
|
||||
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);
|
||||
|
||||
public static readonly PlanSpec Hy2Ladder = new(
|
||||
"hy2_ladder",
|
||||
"Hysteria2 ladder",
|
||||
"UDP train at 10, 25, 50 and 100 Mbit/s for 10 s each; models Brutal fixed-rate congestion control.",
|
||||
[Train(10), Train(25), Train(50), Train(100)]);
|
||||
|
||||
public static readonly PlanSpec RealitySession = new(
|
||||
"reality_session",
|
||||
"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),
|
||||
]);
|
||||
|
||||
public static readonly IReadOnlyList<PlanSpec> All = [Hy2Ladder, RealitySession];
|
||||
|
||||
public static PlanSpec? Find(string id)
|
||||
{
|
||||
foreach (PlanSpec plan in All)
|
||||
{
|
||||
if (plan.Id == id)
|
||||
{
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(string))]
|
||||
internal sealed partial class PlanJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,95 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 后台任务:给已成功结束的测试算结果(所有阶段的块到达之后,或结束一分钟后以现有的块为准),
|
||||
/// 并把长时间没有汇报的测试标记为失败。
|
||||
/// </summary>
|
||||
public sealed partial class TestFinalizer : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan s_poll = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan s_chunkWait = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan s_stale = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IRawStore _raw;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<TestFinalizer> _logger;
|
||||
|
||||
public TestFinalizer(ITestStore tests, IRawStore raw, LiveHub live, TimeProvider time, ILogger<TestFinalizer> logger)
|
||||
{
|
||||
_tests = tests;
|
||||
_raw = raw;
|
||||
_live = live;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
await Task.Delay(s_poll, _time, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 后台循环不能因为一次失败而退出
|
||||
LogFailed(ex);
|
||||
await Task.Delay(s_poll, _time, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>执行一轮(也供测试直接调用)。</summary>
|
||||
public async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
await _tests.FailStaleAsync(now - (long)s_stale.TotalMilliseconds, "test_timeout", now, ct);
|
||||
|
||||
foreach (TestRecord test in await _tests.ListNeedingResultAsync(20, ct))
|
||||
{
|
||||
IReadOnlyList<RawChunkMeta> metas = await _raw.FindAsync(new RawQuery(TestId: test.Id, Limit: 1000), ct);
|
||||
int phases = metas.Where(m => m.Phase is not null).Select(m => m.Phase).Distinct().Count();
|
||||
bool waited = test.FinishedAtMs is { } finished && now - finished >= (long)s_chunkWait.TotalMilliseconds;
|
||||
if (metas.Count == 0 ? !waited : phases < test.PhaseCount && !waited)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var chunks = new List<RawChunk>(metas.Count);
|
||||
foreach (RawChunkMeta meta in metas)
|
||||
{
|
||||
byte[]? bytes = await _raw.ReadAsync(meta, ct);
|
||||
if (bytes is not null && RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
TestResult result = TestEvaluator.Evaluate(test.Plan, chunks);
|
||||
if (chunks.Count < test.PhaseCount)
|
||||
{
|
||||
result.Caveats.Add("partial_data");
|
||||
}
|
||||
|
||||
await _tests.SaveResultAsync(TestService.ToRecord(test.Id, result), ct);
|
||||
_live.Publish(new TestSignal(test.Id));
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "test finalizer iteration failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Monitor.Backend.Analysis;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed class StoredTestMetrics
|
||||
{
|
||||
[JsonPropertyName("metrics")]
|
||||
public Dictionary<string, double> Metrics { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("caveats")]
|
||||
public List<string> Caveats { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public List<TestStepResult> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(StoredTestMetrics))]
|
||||
[JsonSerializable(typeof(Dictionary<string, double?[]>), TypeInfoPropertyName = "SeriesMap")]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
|
||||
internal sealed partial class TestJson : JsonSerializerContext;
|
||||
|
||||
public sealed record ScoreView(double? Score, int Version, bool Estimated, IReadOnlyList<ModelDimension> Dimensions, IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestResultView(
|
||||
IReadOnlyDictionary<string, double> Metrics,
|
||||
IReadOnlyDictionary<string, ScoreView> Scores,
|
||||
IReadOnlyDictionary<string, double?[]> Series,
|
||||
IReadOnlyList<TestStepResult> Steps,
|
||||
IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestDetail(TestRecord Test, IReadOnlyList<string> PhaseKinds, TestResultView? Result);
|
||||
|
||||
public sealed record StartTestRequest(string Plan, IReadOnlyDictionary<string, JsonElement>? Params);
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Backend.Analysis;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
public sealed class TestService
|
||||
{
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time)
|
||||
{
|
||||
_tests = tests;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_live = live;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct)
|
||||
{
|
||||
PlanSpec? plan = Plans.Find(request.Plan);
|
||||
if (plan is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.UnknownPlan, $"unknown plan {request.Plan}");
|
||||
}
|
||||
|
||||
AgentRecord? agent = await _agents.GetAsync(agentId, ct);
|
||||
if (agent is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (agent.Status != NodeStatus.Active)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.AgentNotActive, "agent is not active");
|
||||
}
|
||||
|
||||
// Agent 在 hello 里声明了它支持的采集方法:方案里有它不支持的就不下发(否则要等到阶段开始才失败)
|
||||
string? unsupported = plan.Phases.Select(p => p.Kind).FirstOrDefault(kind => !agent.Kinds.Contains(kind));
|
||||
if (unsupported is not null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
|
||||
}
|
||||
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent);
|
||||
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
|
||||
{
|
||||
return Result<TestRecord>.Fail(error);
|
||||
}
|
||||
|
||||
if (!_gateway.IsOnline(agentId))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.AgentOffline, "agent is offline");
|
||||
}
|
||||
|
||||
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value)))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent");
|
||||
}
|
||||
|
||||
if (await _tests.HasActiveAsync(agentId, probeId, ct))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.TestBusy, "another test is already running on this line");
|
||||
}
|
||||
|
||||
var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time));
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary<string, JsonElement>(request.Params), TestJson.Default.ParamMap);
|
||||
var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null);
|
||||
await _tests.CreateAsync(record, ct);
|
||||
|
||||
Result<bool> sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, Plan = plan.Id, Phases = phaseList }, ct);
|
||||
if (!sent.TryGet(out _, out FleetError? sendError))
|
||||
{
|
||||
await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct);
|
||||
return Result<TestRecord>.Fail(sendError);
|
||||
}
|
||||
|
||||
return Result<TestRecord>.Ok(record);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TestRecord>> CancelAsync(TestId id, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(id, ct);
|
||||
if (test is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MonitorErrors.TestNotFound, "test not found");
|
||||
}
|
||||
|
||||
if (IsTerminal(test.Status))
|
||||
{
|
||||
return Result<TestRecord>.Ok(test);
|
||||
}
|
||||
|
||||
await _gateway.CancelTestAsync(test.AgentId, id, ct);
|
||||
if (!_gateway.IsOnline(test.AgentId))
|
||||
{
|
||||
// 节点已经不在线,没有人会再汇报这个测试
|
||||
await _tests.UpdateStatusAsync(id, TestStatuses.Aborted, null, null, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_live.Publish(new TestSignal(id));
|
||||
}
|
||||
|
||||
return Result<TestRecord>.Ok(await _tests.GetAsync(id, ct) ?? test);
|
||||
}
|
||||
|
||||
/// <summary><c>monitor.test.status</c>:只接受该测试所属 Agent 的汇报。</summary>
|
||||
public async ValueTask OnStatusAsync(NodeId agent, TestStatusParams status, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(status.TestId, ct);
|
||||
if (test is null || test.AgentId != agent || IsTerminal(test.Status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string next = status.Status switch
|
||||
{
|
||||
TestStatuses.Running or TestStatuses.PhaseDone or TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted => status.Status,
|
||||
_ => test.Status,
|
||||
};
|
||||
string stored = next == TestStatuses.PhaseDone ? TestStatuses.Running : next;
|
||||
await _tests.UpdateStatusAsync(status.TestId, stored, status.Phase, status.ErrorCode, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_live.Publish(new TestSignal(status.TestId));
|
||||
}
|
||||
|
||||
public async ValueTask<TestDetail?> GetAsync(TestId id, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(id, ct);
|
||||
if (test is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlanSpec? plan = Plans.Find(test.Plan);
|
||||
TestResultRecord? stored = await _tests.GetResultAsync(id, ct);
|
||||
return new TestDetail(test, plan?.Phases.Select(p => p.Kind).ToList() ?? [], stored is null ? null : ToView(stored));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct);
|
||||
|
||||
public static TestResultView ToView(TestResultRecord stored)
|
||||
{
|
||||
StoredTestMetrics metrics = JsonSerializer.Deserialize(stored.MetricsJson, TestJson.Default.StoredTestMetrics) ?? new StoredTestMetrics();
|
||||
Dictionary<string, double?[]> series = JsonSerializer.Deserialize(stored.SeriesJson, TestJson.Default.SeriesMap) ?? [];
|
||||
var view = new MetricView(metrics.Metrics) { MeasuredOnServicePort = !metrics.Caveats.Contains("port_differs_from_service") };
|
||||
var scores = new Dictionary<string, ScoreView>();
|
||||
foreach ((string id, ModelScore score) in ModelRegistry.ScoreAll(view))
|
||||
{
|
||||
scores[id] = new ScoreView(score.Score, score.Version, score.Estimated, score.Dimensions, score.Caveats);
|
||||
}
|
||||
|
||||
return new TestResultView(metrics.Metrics, scores, series, metrics.Steps, metrics.Caveats);
|
||||
}
|
||||
|
||||
public static TestResultRecord ToRecord(TestId id, TestResult result)
|
||||
{
|
||||
var stored = new StoredTestMetrics { Metrics = result.Metrics, Caveats = result.Caveats, Steps = result.Steps };
|
||||
var series = result.Series.ToDictionary(s => s.Key, s => s.Value.Select(v => double.IsNaN(v) || double.IsInfinity(v) ? (double?)null : v).ToArray());
|
||||
return new TestResultRecord(
|
||||
id,
|
||||
JsonSerializer.SerializeToUtf8Bytes(stored, TestJson.Default.StoredTestMetrics),
|
||||
JsonSerializer.SerializeToUtf8Bytes(series, TestJson.Default.SeriesMap));
|
||||
}
|
||||
|
||||
public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted;
|
||||
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent)
|
||||
{
|
||||
if (overrides is not null)
|
||||
{
|
||||
var unknown = new Dictionary<string, string>();
|
||||
foreach (string key in overrides.Keys)
|
||||
{
|
||||
if (!plan.Phases.Any(p => Kinds.Find(p.Kind)!.Params.Any(s => s.Name == key)))
|
||||
{
|
||||
unknown[$"params.{key}"] = "is not a parameter of any phase of this plan";
|
||||
}
|
||||
}
|
||||
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<List<TestPhase>>(unknown);
|
||||
}
|
||||
}
|
||||
|
||||
double? limitMbps = null;
|
||||
if (agent.LocalLimitsJson is { } limitsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
limitMbps = JsonSerializer.Deserialize(limitsJson, LocalLimitsJson.Default.LocalLimits)?.MaxTotalMbps;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 无法解析就当作没有声明
|
||||
}
|
||||
}
|
||||
|
||||
var phases = new List<TestPhase>();
|
||||
var errors = new Dictionary<string, string>();
|
||||
foreach (PhaseSpec phase in plan.Phases)
|
||||
{
|
||||
KindSpec kind = Kinds.Find(phase.Kind)!;
|
||||
var merged = new Dictionary<string, JsonElement>(phase.Overrides);
|
||||
if (overrides is not null)
|
||||
{
|
||||
foreach ((string key, JsonElement value) in overrides)
|
||||
{
|
||||
if (kind.Params.Any(s => s.Name == key))
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
|
||||
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
||||
{
|
||||
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(kind.Params, merged);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
foreach ((string key, string message) in resolved.Errors)
|
||||
{
|
||||
errors[$"params.{key}"] = message;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
long bits = kind.BudgetBitsPerSecond(resolved.Values!);
|
||||
if (limitMbps is > 0 && bits > limitMbps * 1_000_000)
|
||||
{
|
||||
return Result<List<TestPhase>>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s");
|
||||
}
|
||||
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds;
|
||||
phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? ValidationError.Fail<List<TestPhase>>(errors) : Result<List<TestPhase>>.Ok(phases);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(int))]
|
||||
internal sealed partial class PlanPortJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,17 @@
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>校验失败,带逐字段的原因(键是 JSON 字段路径)。Api 层把它映射成 problem+json 的 <c>errors</c>。</summary>
|
||||
public sealed record ValidationError(string Message, IReadOnlyDictionary<string, string> Fields)
|
||||
: FleetError(MonitorErrors.ValidationFailed, Message)
|
||||
{
|
||||
public static ValidationError Single(string field, string message) =>
|
||||
new("Validation failed", new Dictionary<string, string> { [field] = message });
|
||||
|
||||
public static Result<T> Fail<T>(string field, string message) => Result<T>.Fail(Single(field, message));
|
||||
|
||||
public static Result<T> Fail<T>(IReadOnlyDictionary<string, string> fields) =>
|
||||
Result<T>.Fail(new ValidationError("Validation failed", fields));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Monitor.Backend.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// 宿主开始停止时先关闭所有 Agent 的长连接(Close 握手,不发 revoked:Agent 会重连)。
|
||||
/// 否则 Kestrel 的有序停止要等满 30 秒的超时,才会放弃这些永远不会自己结束的 WebSocket。
|
||||
/// </summary>
|
||||
public sealed class ConnectionShutdown : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private Task? _closing;
|
||||
|
||||
public ConnectionShutdown(IServiceProvider services, IHostApplicationLifetime lifetime)
|
||||
{
|
||||
_services = services;
|
||||
_lifetime = lifetime;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// ApplicationStopping 在有序停止的最开始触发,早于 Kestrel 等待连接结束:这里不阻塞,关闭在后台进行,StopAsync 再等它做完
|
||||
_lifetime.ApplicationStopping.Register(() => _closing = CloseAsync());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_closing is { } closing)
|
||||
{
|
||||
await closing.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _services.GetRequiredService<IFleetConnections>().CloseAllAsync("server shutting down");
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
// 停止过程中,服务已经在释放:Kestrel 的超时兜底
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Application 通往 Agent 连接的端口的实现,建立在 Fleet.Server.Link 的在线表之上。
|
||||
/// Fleet 的连接宿主依赖节点事件处理器,而事件处理器依赖 Application,Application 又依赖这个端口:
|
||||
/// 这里在调用时才解析 Fleet 的服务,打断构造期的循环依赖。
|
||||
/// </summary>
|
||||
public sealed class FleetGateway : IAgentGateway
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public FleetGateway(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
private IFleetConnections Connections => _services.GetRequiredService<IFleetConnections>();
|
||||
|
||||
private IStateNotifier Notifier => _services.GetRequiredService<IStateNotifier>();
|
||||
|
||||
public IReadOnlyCollection<NodeId> OnlineAgents => Connections.OnlineNodes;
|
||||
|
||||
public bool IsOnline(NodeId agent) => Connections.IsOnline(agent);
|
||||
|
||||
public void NotifyTaskSetChanged(NodeId agent) => Notifier.NotifyChanged(agent);
|
||||
|
||||
public void NotifyAllTaskSetsChanged() => Notifier.NotifyAllChanged();
|
||||
|
||||
public async ValueTask NotifyProbeAgentsChangedAsync(ProbeAgentsResult agents, CancellationToken ct)
|
||||
{
|
||||
IFleetConnections connections = Connections;
|
||||
foreach (NodeId node in connections.OnlineNodes)
|
||||
{
|
||||
await connections.NotifyAsync(node, MonitorMethods.ProbeAgentsChanged, agents, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<bool>> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct)
|
||||
{
|
||||
Result<TestRunResult> result = await Connections.RequestAsync(
|
||||
agent, MonitorMethods.TestRun, run, MonitorJsonContext.Default.TestRunParams, MonitorJsonContext.Default.TestRunResult, ct);
|
||||
if (!result.TryGet(out TestRunResult? accepted, out FleetError? error))
|
||||
{
|
||||
return Result<bool>.Fail(error.Code == FleetErrors.NodeOffline ? MonitorErrors.AgentOffline : error.Code, error.Message);
|
||||
}
|
||||
|
||||
return accepted.Accepted ? Result<bool>.Ok(true) : Result<bool>.Fail(MonitorErrors.TestBusy, "the agent did not accept the test");
|
||||
}
|
||||
|
||||
public async ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct) =>
|
||||
await Connections.RequestAsync(
|
||||
agent, MonitorMethods.TestCancel, new TestCancelParams { TestId = test }, MonitorJsonContext.Default.TestCancelParams, MonitorJsonContext.Default.TestRunResult, ct);
|
||||
|
||||
public async ValueTask<Result<bool>> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct)
|
||||
{
|
||||
Result<TestRunResult> result = await Connections.RequestAsync(
|
||||
agent, MonitorMethods.ProbeDisconnect, new ProbeDisconnectParams { ProbeId = probe }, MonitorJsonContext.Default.ProbeDisconnectParams, MonitorJsonContext.Default.TestRunResult, ct);
|
||||
if (!result.TryGet(out TestRunResult? done, out FleetError? error))
|
||||
{
|
||||
return Result<bool>.Fail(error.Code == FleetErrors.NodeOffline ? MonitorErrors.AgentOffline : error.Code, error.Message);
|
||||
}
|
||||
|
||||
return Result<bool>.Ok(done.Accepted);
|
||||
}
|
||||
|
||||
public ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct) => Connections.DisconnectAsync(agent, reason, ct);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Gateway;
|
||||
|
||||
public static class GatewayServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 在 Fleet.Server 之上接入 Monitor:期望状态(TaskSet)、RawChunk 收件箱、<c>monitor.*</c> 方法、注册挂钩和节点事件。
|
||||
/// 需要事先注册 <see cref="IFleetStore"/> 和 <see cref="TimeProvider"/>,并在 <c>AddMonitorApplication</c> 之后调用。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMonitorGateway(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IAgentGateway, FleetGateway>();
|
||||
services.AddHostedService<ConnectionShutdown>();
|
||||
services.AddFleetEnrollment();
|
||||
services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix)
|
||||
.AddState<TaskSetStateProvider>()
|
||||
.AddInbox<RawChunkInbox>()
|
||||
.MapMethods(MapMonitorMethods);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void MapMonitorMethods(RpcRouter<NodeCallContext> router, IServiceProvider services)
|
||||
{
|
||||
router
|
||||
.OnNotify(
|
||||
MonitorMethods.Event,
|
||||
MonitorJsonContext.Default.AgentEventParams,
|
||||
(context, parameters, ct) => services.GetRequiredService<AgentEventService>().OnEventAsync(context.Node.Id, parameters, ct))
|
||||
.OnNotify(
|
||||
MonitorMethods.Host,
|
||||
MonitorJsonContext.Default.HostSnapshot,
|
||||
(context, snapshot, ct) => services.GetRequiredService<HostStatusService>().OnHostAsync(context.Node.Id, snapshot, ct))
|
||||
.OnNotify(
|
||||
MonitorMethods.TestStatus,
|
||||
MonitorJsonContext.Default.TestStatusParams,
|
||||
(context, parameters, ct) => services.GetRequiredService<TestService>().OnStatusAsync(context.Node.Id, parameters, ct))
|
||||
.On(
|
||||
MonitorMethods.ProbeAgents,
|
||||
MonitorJsonContext.Default.ProbeAgentsParams,
|
||||
MonitorJsonContext.Default.ProbeAgentsResult,
|
||||
async (context, parameters, ct) => Result<ProbeAgentsResult>.Ok(await services.GetRequiredService<AgentService>().ProbeAgentsAsync(ct)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Monitor.Backend.Application\Monitor.Backend.Application.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Server.Link\Fleet.Server.Link.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Server.Enrollment\Fleet.Server.Enrollment.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
using Fleet.Server;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// RawChunk 收件箱(二进制通道 16):持久化之后才返回,Fleet 随后才回复 ACK,按 chunk_id 去重。
|
||||
/// 永远不可能被接受的块(损坏、冒名、未知 Kind)也确认掉并记录原因,否则节点会无限重发。
|
||||
/// </summary>
|
||||
public sealed class RawChunkInbox : IInbox
|
||||
{
|
||||
private readonly IngestService _ingest;
|
||||
|
||||
public RawChunkInbox(IngestService ingest)
|
||||
{
|
||||
_ingest = ingest;
|
||||
}
|
||||
|
||||
public byte Channel => MonitorChannels.RawChunk;
|
||||
|
||||
public async ValueTask<InboxOutcome> HandleAsync(NodeRecord node, Guid msgId, ReadOnlyMemory<byte> payload, CancellationToken ct) =>
|
||||
await _ingest.IngestAsync(node.Id, msgId, payload, ct) switch
|
||||
{
|
||||
IngestOutcome.Stored => InboxOutcome.Stored,
|
||||
_ => InboxOutcome.Duplicate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// 期望状态:为每个 Agent 下发完整的 <see cref="TaskSet"/> 快照(有效任务集 + Probe 公钥)。
|
||||
/// 节点应用之后回传 <c>applied_version</c> 和 <c>rejected[]</c>(<c>unsupported</c>、<c>budget_exceeded</c>、<c>policy_denied</c>),面板上可以看到。
|
||||
/// </summary>
|
||||
public sealed class TaskSetStateProvider : IStateProvider
|
||||
{
|
||||
private readonly TaskPlanner _planner;
|
||||
private readonly IAgentStore _agents;
|
||||
|
||||
public TaskSetStateProvider(TaskPlanner planner, IAgentStore agents)
|
||||
{
|
||||
_planner = planner;
|
||||
_agents = agents;
|
||||
}
|
||||
|
||||
public string Type => TaskSet.StateType;
|
||||
|
||||
public async ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct)
|
||||
{
|
||||
DesiredTaskSet? desired = await _planner.GetDesiredAsync(node.Id, ct);
|
||||
return desired is null ? null : new StateDocument(desired.Version, JsonSerializer.SerializeToElement(desired.Document, MonitorJsonContext.Default.TaskSet));
|
||||
}
|
||||
|
||||
public async ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct)
|
||||
{
|
||||
string? rejected = result.Rejected.Count == 0 ? null : JsonSerializer.Serialize(result.Rejected, GatewayJson.Default.ListRejectedItem);
|
||||
await _agents.SetAppliedTaskStateAsync(node.Id, result.AppliedVersion, rejected, ct);
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(List<RejectedItem>))]
|
||||
internal sealed partial class GatewayJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<ProjectReference Include="..\Monitor.Backend.Application\Monitor.Backend.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,240 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Monitor.Backend.Storage.Sqlite;
|
||||
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 3;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
int version = connection.Scalar<int>("PRAGMA user_version");
|
||||
if (version > CurrentVersion)
|
||||
{
|
||||
throw new InvalidOperationException($"database schema version {version} is newer than this build supports ({CurrentVersion})");
|
||||
}
|
||||
|
||||
if (version < 1)
|
||||
{
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec(V1);
|
||||
connection.Exec("PRAGMA user_version = 1");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 2)
|
||||
{
|
||||
// 测试属于一条线路(Probe,Agent):旧测试没有 Probe,留空
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE tests ADD COLUMN probe_id TEXT NOT NULL DEFAULT ''");
|
||||
connection.Exec("CREATE INDEX ix_tests_line ON tests (agent_id, probe_id, requested_at)");
|
||||
connection.Exec("PRAGMA user_version = 2");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 3)
|
||||
{
|
||||
// 主机资源占用的历史(每个 Agent 每分钟一行)
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("""
|
||||
CREATE TABLE host_samples (
|
||||
agent_id TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
cpu REAL,
|
||||
mem_used INTEGER,
|
||||
mem_total INTEGER,
|
||||
disk_used INTEGER,
|
||||
disk_total INTEGER,
|
||||
net_rx_bps INTEGER,
|
||||
net_tx_bps INTEGER,
|
||||
uptime INTEGER,
|
||||
PRIMARY KEY (agent_id, at)
|
||||
) WITHOUT ROWID;
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 3");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增 Kind 或指标都不需要改表结构:指标是 <c>metric_*.metrics</c> 里的一个键,Kind 只是 <c>raw_chunks.kind</c> 的一个取值。
|
||||
/// </summary>
|
||||
private const string V1 = """
|
||||
CREATE TABLE agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
pubkey BLOB NOT NULL,
|
||||
tls_spki BLOB NOT NULL,
|
||||
public_host TEXT NOT NULL DEFAULT '',
|
||||
probe_tcp_port INTEGER NOT NULL DEFAULT 7443,
|
||||
probe_udp_ports TEXT NOT NULL DEFAULT '[7443]',
|
||||
labels TEXT NOT NULL DEFAULT '[]',
|
||||
public_visible INTEGER NOT NULL DEFAULT 1,
|
||||
kinds TEXT NOT NULL DEFAULT '[]',
|
||||
agent_version TEXT,
|
||||
local_limits TEXT,
|
||||
task_version INTEGER NOT NULL DEFAULT 0,
|
||||
applied_task_version INTEGER NOT NULL DEFAULT 0,
|
||||
rejected TEXT,
|
||||
task_hash TEXT,
|
||||
meta TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER,
|
||||
row_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- 同名节点只允许一个已建立的(pending 的可以重名,等待管理员接管或改名)
|
||||
CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending';
|
||||
|
||||
CREATE TABLE join_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_sha256 BLOB NOT NULL UNIQUE,
|
||||
note TEXT,
|
||||
labels TEXT NOT NULL DEFAULT '[]',
|
||||
auto_approve INTEGER NOT NULL,
|
||||
max_uses INTEGER NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
pubkey BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash BLOB NOT NULL,
|
||||
password_salt BLOB NOT NULL,
|
||||
iterations INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id_sha256 BLOB PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
token_sha256 BLOB NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
|
||||
base_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
schedule TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL,
|
||||
row_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE tests (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
phase INTEGER,
|
||||
phase_count INTEGER NOT NULL DEFAULT 0,
|
||||
requested_at INTEGER NOT NULL,
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER
|
||||
);
|
||||
CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at);
|
||||
CREATE TABLE test_results (
|
||||
test_id TEXT PRIMARY KEY,
|
||||
metrics BLOB NOT NULL,
|
||||
series BLOB NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE raw_segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
day TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE raw_chunks (
|
||||
chunk_id TEXT PRIMARY KEY,
|
||||
segment_id INTEGER NOT NULL REFERENCES raw_segments(id),
|
||||
offset INTEGER NOT NULL,
|
||||
length INTEGER NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
series TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
test_id TEXT,
|
||||
phase INTEGER,
|
||||
start_ts INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
partial INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts);
|
||||
CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL;
|
||||
CREATE INDEX ix_raw_segment ON raw_chunks (segment_id);
|
||||
|
||||
CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
|
||||
CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
|
||||
CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
|
||||
CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
|
||||
CREATE INDEX ix_metric_10s_time ON metric_10s (window_start);
|
||||
CREATE INDEX ix_metric_1m_time ON metric_1m (window_start);
|
||||
CREATE INDEX ix_metric_5m_time ON metric_5m (window_start);
|
||||
|
||||
CREATE TABLE metric_versions (
|
||||
metric_id TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
recompute_from INTEGER,
|
||||
recompute_cursor INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE analysis_queue (
|
||||
agent_id TEXT NOT NULL,
|
||||
series TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
not_before INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (agent_id, series, window_start)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_queue_due ON analysis_queue (not_before);
|
||||
CREATE TABLE rollup_dirty (
|
||||
level INTEGER NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
series TEXT NOT NULL,
|
||||
bucket_start INTEGER NOT NULL,
|
||||
PRIMARY KEY (level, agent_id, series, bucket_start)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
agent_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
detail TEXT
|
||||
);
|
||||
CREATE INDEX ix_events_time ON events (at);
|
||||
CREATE INDEX ix_events_agent ON events (agent_id, at);
|
||||
""";
|
||||
}
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user