chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
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.&lt;模型id&gt;)。</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 &lt;api_token&gt;</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>&lt;agent_id&gt;~&lt;probe_id&gt;</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:&lt;probe_id&gt;</c>,对外的 id 是 <c>&lt;agent_id&gt;~&lt;probe_id&gt;</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