refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,16 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.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,73 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
||||
|
||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
|
||||
/// <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 shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
||||
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
|
||||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.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("moshiro_agents_online", "Agents with an open control connection.", _gateway.OnlineAgents.Count);
|
||||
Metric("moshiro_chunks_ingested_total", "Raw chunks stored.", _counters.ChunksIngested, "counter");
|
||||
Metric("moshiro_chunks_duplicate_total", "Raw chunks received more than once.", _counters.ChunksDuplicate, "counter");
|
||||
Metric("moshiro_chunks_discarded_total", "Raw chunks rejected as invalid.", _counters.ChunksDiscarded, "counter");
|
||||
Metric("moshiro_ingest_bytes_total", "Bytes of raw chunks stored.", _counters.BytesIngested, "counter");
|
||||
Metric("moshiro_windows_analyzed_total", "Minute windows computed.", _counters.WindowsAnalyzed, "counter");
|
||||
Metric("moshiro_analysis_queue_length", "Minute windows waiting to be computed.", await _metrics.QueueLengthAsync(ct));
|
||||
Metric("moshiro_recompute_windows_done", "Windows recomputed by the current backfill.", progress.WindowsDone);
|
||||
Metric("moshiro_recompute_windows_total", "Windows to recompute in the current backfill.", progress.WindowsTotal);
|
||||
Metric("moshiro_raw_bytes", "Disk used by raw segment files.", _raw.TotalBytes);
|
||||
Metric("moshiro_sse_connections", "Open server-sent event connections.", _counters.SseConnections);
|
||||
return text.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.RawExpired, "the raw data has expired")
|
||||
: Result<(RawChunkMeta, byte[])>.Fail(MoShiroErrors.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,130 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.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 SiteShowBuild = "site.show_build";
|
||||
public const string AutoLink = "fleet.auto_link";
|
||||
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(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend, agents and probes."),
|
||||
new(AutoLink, "bool", "true", 0, 1, "Whether every probe connects to every agent automatically; when off, only the links chosen by hand are connected."),
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>访客能不能看到版本、tag 和构建时间(站点公开之后的另一道开关,默认关)。</summary>
|
||||
public async ValueTask<bool> IsShowBuildAsync(CancellationToken ct) => await _store.GetAsync(SiteShowBuild, ct) == "true";
|
||||
|
||||
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,51 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary><c>moshiro.event</c>:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。</summary>
|
||||
public sealed class AgentEventService
|
||||
{
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_time = time;
|
||||
_nodes = nodes;
|
||||
}
|
||||
|
||||
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));
|
||||
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote);
|
||||
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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,176 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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;
|
||||
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_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);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
await _links.ForgetNodeAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。</summary>
|
||||
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(string probeId, CancellationToken ct)
|
||||
{
|
||||
LinkSnapshot links = await _links.SnapshotAsync(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 ProbeAgentList { Version = version };
|
||||
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value)))
|
||||
{
|
||||
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 的期望状态,并把新的 Agent 列表推给 Probe(都是期望状态,一起通知)。</summary>
|
||||
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
|
||||
{
|
||||
await _planner.BumpProbeAgentsVersionAsync(ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// <c>moshiro.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,68 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。</summary>
|
||||
public sealed class LinkSnapshot
|
||||
{
|
||||
private readonly Dictionary<(string Probe, string Agent), bool> _overrides;
|
||||
|
||||
public LinkSnapshot(bool auto, Dictionary<(string Probe, string Agent), bool> overrides)
|
||||
{
|
||||
Auto = auto;
|
||||
_overrides = overrides;
|
||||
}
|
||||
|
||||
/// <summary>自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。</summary>
|
||||
public bool Auto { get; }
|
||||
|
||||
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。</summary>
|
||||
public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null;
|
||||
|
||||
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary>
|
||||
public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认),
|
||||
/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(<c>link.<probe>.<agent></c>,值 1 或 0,空表示没设置)。
|
||||
/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
|
||||
/// </summary>
|
||||
public sealed class LinkService
|
||||
{
|
||||
private const string Prefix = "link.";
|
||||
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public LinkService(ISettingsStore settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<string, string> all = await _settings.GetAllAsync(ct);
|
||||
var overrides = new Dictionary<(string, string), bool>();
|
||||
foreach ((string key, string value) in all)
|
||||
{
|
||||
string[] parts = key.Split('.');
|
||||
if (parts.Length == 3 && parts[0] + "." == Prefix && value is "1" or "0")
|
||||
{
|
||||
overrides[(parts[1], parts[2])] = value == "1";
|
||||
}
|
||||
}
|
||||
|
||||
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides);
|
||||
}
|
||||
|
||||
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary>
|
||||
public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) =>
|
||||
_settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
|
||||
|
||||
/// <summary>Probe 或 Agent 被删除:清掉所有涉及它的手动设置。</summary>
|
||||
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
foreach (string key in (await _settings.GetAllAsync(ct)).Keys.Where(k => k.StartsWith(Prefix, StringComparison.Ordinal) && k.Split('.').Skip(1).Contains(nodeId)))
|
||||
{
|
||||
await _settings.SetAsync(key, "", ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口。</summary>
|
||||
public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentService _service;
|
||||
|
||||
public MoShiroEnrollmentHook(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(MoShiroJsonContext.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 MoShiroNodeEventSink : INodeEventSink
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly LiveHub _live;
|
||||
private readonly AgentService _service;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly ReleaseService _releases;
|
||||
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases)
|
||||
{
|
||||
_releases = releases;
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_live = live;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct)
|
||||
{
|
||||
NodeId id = nodeEvent.Node.Id;
|
||||
if (id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
await OnProbeEventAsync(id, nodeEvent, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
_releases.RecordStatus(id, null);
|
||||
_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(MoShiroJsonContext.Default.AgentHeartbeatExtra);
|
||||
state.LastHeartbeat = beat;
|
||||
state.LastHeartbeatAtMs = nodeEvent.AtMs;
|
||||
_releases.RecordStatus(id, beat?.Update);
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
foreach (ConnectedProbe p in beat.Probes)
|
||||
{
|
||||
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probe 自己连上来了:hello 里带着它的系统、架构和构建信息;上线下线只推给 SSE,不记事件。</summary>
|
||||
private async ValueTask OnProbeEventAsync(NodeId id, NodeEvent nodeEvent, CancellationToken ct)
|
||||
{
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, true, nodeEvent.AtMs));
|
||||
break;
|
||||
case NodeEventKind.Hello:
|
||||
if (nodeEvent.Hello?.Meta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
||||
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 格式不对就忽略
|
||||
}
|
||||
}
|
||||
|
||||
await _releases.OfferToNodeAsync(id, ct);
|
||||
break;
|
||||
case NodeEventKind.Offline:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
||||
_releases.RecordStatus(id, null);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
_releases.RecordStatus(id, extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra)?.Update);
|
||||
}
|
||||
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(MoShiroCapabilities.KindsPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
kinds.AddRange(capability[MoShiroCapabilities.KindsPrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
}
|
||||
|
||||
AgentHelloMeta meta = new();
|
||||
if (hello.Meta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = raw.Deserialize(MoShiroJsonContext.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);
|
||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||
await _releases.OfferToNodeAsync(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,193 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
|
||||
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
|
||||
|
||||
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥;<c>Server</c> 是 Probe 要连的面板地址。</summary>
|
||||
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, 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, bool Online, IReadOnlyList<ProbeConnection> Connections)
|
||||
{
|
||||
public bool Revoked => Probe.RevokedAtMs is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
|
||||
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。
|
||||
/// 一条线路是一对(Probe,Agent)。
|
||||
/// </summary>
|
||||
public sealed partial class ProbeService
|
||||
{
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, string server, 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(MoShiroErrors.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(BuildFile(probe, identity, server));
|
||||
}
|
||||
|
||||
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(MoShiroErrors.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(MoShiroErrors.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, string server, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (probe.RevokedAtMs is not null)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.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(BuildFile(updated, identity, server));
|
||||
}
|
||||
|
||||
/// <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(MoShiroErrors.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)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
await _links.ForgetNodeAsync(id, ct);
|
||||
_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(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (await _agents.GetAsync(agentId, ct) is null)
|
||||
{
|
||||
return Result<bool>.Fail(MoShiroErrors.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, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private static ProbeIdentityFile BuildFile(ProbeRecord probe, NodeIdentity identity, string server) =>
|
||||
new(new ProbeId(probe.Id), Convert.ToBase64String(identity.ExportPkcs8()), server, 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 MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,63 @@
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public static class ApplicationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 Application 层的全部服务。需要事先注册端口的实现(存储、<see cref="IAgentGateway"/>)和 <see cref="TimeProvider"/>。
|
||||
/// 后台任务按这里的顺序启动:<see cref="StartupTasks"/> 必须在最前面。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMoShiroApplication(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<NodeInfoService>();
|
||||
services.AddSingleton<ProbeService>();
|
||||
services.AddSingleton<LinkService>();
|
||||
services.AddSingleton<LineService>();
|
||||
services.AddSingleton<TestService>();
|
||||
services.AddSingleton<IngestService>();
|
||||
services.AddSingleton<WindowComputer>();
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
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, MoShiroEnrollmentHook>();
|
||||
services.AddSingleton<INodeEventSink, MoShiroNodeEventSink>();
|
||||
|
||||
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,32 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public enum PrincipalKind
|
||||
{
|
||||
Anonymous,
|
||||
Session,
|
||||
Token,
|
||||
}
|
||||
|
||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
|
||||
public enum ApiScope
|
||||
{
|
||||
None = 0,
|
||||
Release = 1,
|
||||
Read = 2,
|
||||
Admin = 3,
|
||||
}
|
||||
|
||||
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 bool CanPublishRelease => Scope is ApiScope.Release or 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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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 switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => 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(MoShiroErrors.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" or "release"))
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
|
||||
}
|
||||
|
||||
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 MoShiro.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 MoShiro.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,74 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>Backend 的可调参数(保留期等也可以通过 settings 改,这里是默认值与硬上限)。</summary>
|
||||
public sealed class BackendOptions
|
||||
{
|
||||
/// <summary>数据目录:<c>moshiro.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>
|
||||
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
|
||||
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
|
||||
/// </summary>
|
||||
public double PhaseDurationScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
|
||||
/// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
public string? InitialAdminPassword { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\MoShiro.Backend.Analysis\MoShiro.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,62 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
|
||||
namespace MoShiro.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>注册之后 MoShiro 的补充:公开地址和端口。</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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,86 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 把「ASN 和组织名」归成几个常见的国内运营商:mobile / telecom / unicom / broadnet;别的是 other(显示组织名)。
|
||||
/// 只用来挑展示用的图标和中文名。
|
||||
/// </summary>
|
||||
public static class IspClassifier
|
||||
{
|
||||
public const string Mobile = "mobile";
|
||||
public const string Telecom = "telecom";
|
||||
public const string Unicom = "unicom";
|
||||
public const string Broadnet = "broadnet";
|
||||
public const string Other = "other";
|
||||
|
||||
private static readonly HashSet<int> s_mobile = [9808, 24400, 24445, 56040, 56041, 56042, 56044, 56046, 56047, 56048, 132525, 138407];
|
||||
private static readonly HashSet<int> s_telecom = [4134, 4809, 4811, 4812, 4813, 9394, 17638, 23724, 38283, 58466, 58519, 136188, 137693];
|
||||
private static readonly HashSet<int> s_unicom = [4808, 4837, 9929, 10099, 17621, 17622, 17623, 17816, 136958, 138421];
|
||||
private static readonly HashSet<int> s_broadnet = [63838, 140061];
|
||||
|
||||
public static (string Key, string Name) Classify(int? asn, string? organization)
|
||||
{
|
||||
if (asn is { } a)
|
||||
{
|
||||
if (s_mobile.Contains(a))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (s_telecom.Contains(a))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (s_unicom.Contains(a))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (s_broadnet.Contains(a))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
}
|
||||
|
||||
string org = organization ?? string.Empty;
|
||||
if (Has(org, "china mobile") || Has(org, "cmnet") || Has(org, "中国移动"))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (Has(org, "china telecom") || Has(org, "chinanet") || Has(org, "中国电信"))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (Has(org, "china unicom") || Has(org, "cncgroup") || Has(org, "中国联通"))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (Has(org, "china broadnet") || Has(org, "中国广电"))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
|
||||
return (Other, Shorten(org));
|
||||
}
|
||||
|
||||
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
||||
internal static string Shorten(string organization)
|
||||
{
|
||||
string name = organization.Trim();
|
||||
foreach (string suffix in new[] { ", Inc.", " Inc.", ", LLC", " LLC", " Co.,Ltd.", " Co., Ltd.", " Co.,Ltd", " Co., Ltd", " Ltd.", " Ltd", " Corporation", " Corp.", " Limited", " GmbH", " B.V.", " S.A." })
|
||||
{
|
||||
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[..^suffix.Length].TrimEnd(',', ' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return name.Length > 28 ? name[..28].TrimEnd() : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
|
||||
/// <b>不含任何 IP 地址、城市、ASN 或经纬度</b>——这些只在查询的那一刻用到,不留下,也不对外。
|
||||
/// </summary>
|
||||
public sealed record NodeInfo(
|
||||
string NodeId,
|
||||
string Kind,
|
||||
string? Os,
|
||||
string? OsName,
|
||||
string? Arch,
|
||||
string? Country,
|
||||
string? IspKey,
|
||||
string? IspName,
|
||||
string? Version = null,
|
||||
string? Tag = null,
|
||||
string? Commit = null,
|
||||
string? BuiltAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
||||
/// 这个字段不会出现在任何对外的接口里。
|
||||
/// </summary>
|
||||
public sealed record NodeMeta(NodeInfo Info, string? GeoHost, long GeoAtMs, long UpdatedAtMs);
|
||||
|
||||
public static class NodeKinds
|
||||
{
|
||||
public const string Agent = "agent";
|
||||
public const string Probe = "probe";
|
||||
}
|
||||
|
||||
/// <summary>IP 归属查询的结果(只取需要的几项)。</summary>
|
||||
public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
|
||||
/// <list type="bullet">
|
||||
/// <item>Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
|
||||
/// <item>只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。</item>
|
||||
/// <item>查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class NodeInfoService
|
||||
{
|
||||
/// <summary>同一个地址已经查过的结果保留多久,过期后在节点下次出现时重新查(运营商偶尔会变)。</summary>
|
||||
private static readonly TimeSpan s_geoTtl = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>查询失败之后多久再试。</summary>
|
||||
private static readonly TimeSpan s_retryAfter = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly INodeMetaStore _store;
|
||||
private readonly IGeoLookup _geo;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ConcurrentDictionary<string, NodeMeta> _cache = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _inflight = new();
|
||||
private Task? _load;
|
||||
|
||||
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_geo = geo;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>所有节点的展示信息(Agent 和 Probe)。</summary>
|
||||
public async ValueTask<IReadOnlyList<NodeInfo>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
return [.. _cache.Values.Select(m => m.Info)];
|
||||
}
|
||||
|
||||
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
|
||||
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
||||
{
|
||||
if (platform is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
string? os = Clean(platform.Os, 16);
|
||||
string? name = Clean(platform.Name, 40);
|
||||
string? arch = Clean(platform.Arch, 16);
|
||||
string? version = Clean(platform.Version, 32);
|
||||
string? tag = Clean(platform.Tag, 64);
|
||||
string? commit = Clean(platform.Commit, 40);
|
||||
string? builtAt = Clean(platform.BuiltAt, 32);
|
||||
NodeInfo i = current.Info;
|
||||
if (i.Os == os && i.OsName == name && i.Arch == arch && i.Version == version && i.Tag == tag && i.Commit == commit && i.BuiltAt == builtAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveAsync(current with { Info = i with { Os = os, OsName = name, Arch = arch, Version = version, Tag = tag, Commit = commit, BuiltAt = builtAt }, UpdatedAtMs = Now() }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
|
||||
/// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
|
||||
/// </summary>
|
||||
public async ValueTask ObserveAddressAsync(string nodeId, string kind, string? host, CancellationToken ct)
|
||||
{
|
||||
host = host?.Trim();
|
||||
if (string.IsNullOrEmpty(host) || !IsPublic(host) || !_inflight.TryAdd(nodeId, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
long now = Now();
|
||||
bool same = string.Equals(current.GeoHost, host, StringComparison.OrdinalIgnoreCase);
|
||||
bool fresh = current.Info.Country is not null && now - current.GeoAtMs < s_geoTtl.TotalMilliseconds;
|
||||
bool recentlyFailed = current.Info.Country is null && now - current.GeoAtMs < s_retryAfter.TotalMilliseconds;
|
||||
if (same && (fresh || recentlyFailed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GeoInfo? geo = await _geo.LookupAsync(host, ct);
|
||||
if (geo is null || geo.IsPrivate || string.IsNullOrEmpty(geo.Country))
|
||||
{
|
||||
// 记下这次没查到(地址和时间),免得每次心跳都去查
|
||||
await SaveAsync(current with { GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
string country = geo.Country.ToUpperInvariant();
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflight.TryRemove(nodeId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Agent 报告某个 Probe 连着它:在后台用 Probe 的来源地址查国家和运营商(系统信息是 Probe 自己连上面板时报的)。出错只吞掉。</summary>
|
||||
public void ObserveProbeInBackground(string probeId, string? remote) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await ObserveAddressAsync(probeId, NodeKinds.Probe, remote, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 展示信息拿不到也没关系,下次再试
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Agent 打招呼(hello):记下它的系统信息,并用它的公开地址在后台查国家和运营商。</summary>
|
||||
public void ObserveAgentInBackground(string agentId, string? publicHost, PlatformInfo? platform) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await SetPlatformAsync(agentId, NodeKinds.Agent, platform, cts.Token);
|
||||
await ObserveAddressAsync(agentId, NodeKinds.Agent, publicHost, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 同上
|
||||
}
|
||||
});
|
||||
|
||||
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_cache.TryRemove(nodeId, out _);
|
||||
await _store.DeleteAsync(nodeId, ct);
|
||||
}
|
||||
|
||||
/// <summary>公网地址才值得查:回环、内网、CGNAT、链路本地、未指定的地址一律不发出去。域名放行(由查询服务自己解析)。</summary>
|
||||
public static bool IsPublic(string host)
|
||||
{
|
||||
if (!IPAddress.TryParse(host, out IPAddress? ip))
|
||||
{
|
||||
return host.Contains('.', StringComparison.Ordinal) && !host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) && !host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(ip) || ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any) || ip.Equals(IPAddress.None))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
{
|
||||
ip = ip.MapToIPv4();
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] b = ip.GetAddressBytes();
|
||||
return !(b[0] == 10
|
||||
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| (b[0] == 169 && b[1] == 254)
|
||||
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|
||||
|| b[0] == 0
|
||||
|| b[0] >= 224);
|
||||
}
|
||||
|
||||
byte[] v6 = ip.GetAddressBytes();
|
||||
bool linkLocal = v6[0] == 0xfe && (v6[1] & 0xc0) == 0x80;
|
||||
bool uniqueLocal = (v6[0] & 0xfe) == 0xfc;
|
||||
bool multicast = v6[0] == 0xff;
|
||||
return !(linkLocal || uniqueLocal || multicast);
|
||||
}
|
||||
|
||||
/// <summary>第一次用到时从库里读一次(只读一次,之后都在内存里)。</summary>
|
||||
private async ValueTask EnsureLoadedAsync(CancellationToken ct)
|
||||
{
|
||||
Task load = Volatile.Read(ref _load) ?? Interlocked.CompareExchange(ref _load, LoadAsync(), null) ?? _load!;
|
||||
await load.WaitAsync(ct);
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
foreach (NodeMeta meta in await _store.ListAsync(CancellationToken.None))
|
||||
{
|
||||
_cache.TryAdd(meta.Info.NodeId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_cache[meta.Info.NodeId] = meta;
|
||||
await _store.SaveAsync(meta, ct);
|
||||
}
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
|
||||
private static NodeMeta Empty(string nodeId, string kind) => new(new NodeInfo(nodeId, kind, null, null, null, null, null, null), null, 0, 0);
|
||||
|
||||
/// <summary>节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。</summary>
|
||||
private static string? Clean(string? text, int max)
|
||||
{
|
||||
string trimmed = text?.Trim() ?? string.Empty;
|
||||
return trimmed.Length == 0 ? null : trimmed.Length > max ? trimmed[..max] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Application 通往 Agent 连接的端口(由 Gateway 实现):下发状态变化、发起按需测试。
|
||||
/// </summary>
|
||||
public interface IAgentGateway
|
||||
{
|
||||
IReadOnlyCollection<NodeId> OnlineAgents { get; }
|
||||
|
||||
/// <summary>所有在线的节点(Agent 和 Probe)。</summary>
|
||||
IReadOnlyCollection<NodeId> OnlineNodes { get; }
|
||||
|
||||
bool IsOnline(NodeId agent);
|
||||
|
||||
/// <summary>某个 Agent 的期望状态变了:Gateway 重新计算并推送。</summary>
|
||||
void NotifyTaskSetChanged(NodeId agent);
|
||||
|
||||
void NotifyAllTaskSetsChanged();
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>把发布清单和签名原样交给节点(通知,不等回复)。</summary>
|
||||
ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct);
|
||||
|
||||
/// <summary>请求节点更新到它手里的清单。节点不在线返回 false。</summary>
|
||||
ValueTask<bool> NotifyUpdateApplyAsync(NodeId node, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace MoShiro.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,215 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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);
|
||||
}
|
||||
|
||||
/// <summary>节点的展示信息(系统、架构、国家、运营商)。</summary>
|
||||
public interface INodeMetaStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask SaveAsync(NodeMeta meta, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteAsync(string nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>查一个 IP(或域名)的归属:国家、ASN、组织。实现负责超时和失败(失败返回 null,不抛)。</summary>
|
||||
public interface IGeoLookup
|
||||
{
|
||||
ValueTask<GeoInfo?> LookupAsync(string host, 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);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string id, 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, MoShiro.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 MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.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,107 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>一条线路 = 一对(Agent,Probe)。存储里它的序列是 <c>line:<probe_id></c>,对外的 id 是 <c><agent_id>~<probe_id></c>。</summary>
|
||||
public readonly record struct LineKey(NodeId Agent, ProbeId Probe)
|
||||
{
|
||||
public string Series => RawChunkHeader.LinePrefix + Probe.Value;
|
||||
|
||||
public string Id => Agent.Value + "~" + Probe.Value;
|
||||
|
||||
public static bool TryParse(string text, out LineKey key)
|
||||
{
|
||||
int split = text.IndexOf('~', StringComparison.Ordinal);
|
||||
if (split <= 0 || split == text.Length - 1)
|
||||
{
|
||||
key = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new LineKey(new NodeId(text[..split]), new ProbeId(text[(split + 1)..]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe)
|
||||
{
|
||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id));
|
||||
|
||||
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id);
|
||||
|
||||
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id);
|
||||
}
|
||||
|
||||
/// <summary>线路目录:把 id 解析成(Agent,Probe),列出所有线路。</summary>
|
||||
public sealed class LineService
|
||||
{
|
||||
private readonly AgentService _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public LineService(AgentService agents, IProbeStore probes, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
}
|
||||
|
||||
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</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)];
|
||||
LinkSnapshot links = await _links.SnapshotAsync(ct);
|
||||
return [.. agents.Where(include).SelectMany(a => probes.Where(p => links.IsLinked(p.Id, a.Agent.Id.Value)).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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)];
|
||||
if (active.Length != 1)
|
||||
{
|
||||
return Result<LineView>.Fail(MoShiroErrors.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.UnknownMetric, $"unknown metric {id}");
|
||||
}
|
||||
|
||||
scores.Add(model);
|
||||
}
|
||||
else if (MetricRegistry.TryFindExport(id, out _, out _))
|
||||
{
|
||||
exports.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Result<MetricSelection>.Fail(MoShiroErrors.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 MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.InvalidRange, "range must look like 5m, 6h or 7d");
|
||||
}
|
||||
|
||||
to = nowMs;
|
||||
from = nowMs - span;
|
||||
relative = true;
|
||||
}
|
||||
|
||||
if (from >= to)
|
||||
{
|
||||
return Result<QueryRange>.Fail(MoShiroErrors.InvalidRange, "from must be earlier than to");
|
||||
}
|
||||
|
||||
if (to - from > MaxSpanMs)
|
||||
{
|
||||
return Result<QueryRange>.Fail(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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 MoShiro.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 MoShiro.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 MoShiro.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 MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,95 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 LinkService _links;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_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);
|
||||
LinkSnapshot links = await _links.SnapshotAsync(ct);
|
||||
|
||||
var document = new TaskSet();
|
||||
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 && links.IsLinked(p.Id, agentId.Value)))
|
||||
{
|
||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) });
|
||||
}
|
||||
|
||||
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.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,259 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
public sealed class TestService
|
||||
{
|
||||
private readonly double _phaseScale;
|
||||
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, BackendOptions options)
|
||||
{
|
||||
_phaseScale = options.PhaseDurationScale;
|
||||
_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(MoShiroErrors.UnknownPlan, $"unknown plan {request.Plan}");
|
||||
}
|
||||
|
||||
AgentRecord? agent = await _agents.GetAsync(agentId, ct);
|
||||
if (agent is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (agent.Status != NodeStatus.Active)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.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, _phaseScale);
|
||||
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
|
||||
{
|
||||
return Result<TestRecord>.Fail(error);
|
||||
}
|
||||
|
||||
if (!_gateway.IsOnline(agentId))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentOffline, "agent is offline");
|
||||
}
|
||||
|
||||
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value)))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent");
|
||||
}
|
||||
|
||||
if (await _tests.HasActiveAsync(agentId, probeId, ct))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.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(MoShiroErrors.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>moshiro.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, double phaseScale)
|
||||
{
|
||||
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(MoShiroErrors.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 : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale));
|
||||
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,158 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>面板记下的、CI 签名的发布清单(只留每个组件最新的一份)。<c>Manifest</c> 是签名对象的原始文本,面板一个字节也不改。</summary>
|
||||
public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature);
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||
/// </summary>
|
||||
public sealed partial class ReleaseService
|
||||
{
|
||||
public const int MaxManifestBytes = 4096;
|
||||
public const int MaxSignatureBytes = 512;
|
||||
private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe];
|
||||
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
|
||||
|
||||
public ReleaseService(ISettingsStore settings, IAgentGateway gateway)
|
||||
{
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
}
|
||||
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。只要格式对、<c>seq</c> 比现有的新就收下(签名由节点验证)。</summary>
|
||||
public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
|
||||
{
|
||||
if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem))
|
||||
{
|
||||
return ValidationError.Fail<ReleaseOffer>("manifest", problem);
|
||||
}
|
||||
|
||||
ReleaseOffer? current = await GetAsync(offer.Component, ct);
|
||||
if (current is not null && offer.Seq <= current.Seq)
|
||||
{
|
||||
return Result<ReleaseOffer>.Fail(MoShiroErrors.NameConflict, $"a release with seq {current.Seq} or newer is already registered");
|
||||
}
|
||||
|
||||
await _settings.SetAsync(Key(offer.Component, "manifest"), offer.Manifest, ct);
|
||||
await _settings.SetAsync(Key(offer.Component, "signature"), offer.Signature, ct);
|
||||
foreach (NodeId node in OnlineOf(offer.Component))
|
||||
{
|
||||
await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct);
|
||||
}
|
||||
|
||||
return Result<ReleaseOffer>.Ok(offer);
|
||||
}
|
||||
|
||||
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
|
||||
{
|
||||
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
|
||||
string? signature = await _settings.GetAsync(Key(component, "signature"), ct);
|
||||
return manifest is not null && signature is not null && TryParse(manifest, signature, out ReleaseOffer? offer, out _) && offer.Component == component ? offer : null;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ReleaseOffer>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var list = new List<ReleaseOffer>();
|
||||
foreach (string component in s_components)
|
||||
{
|
||||
if (await GetAsync(component, ct) is { } offer)
|
||||
{
|
||||
list.Add(offer);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>节点刚连上:把它这个组件最新的清单补发一份(节点离线时发布的也不会错过)。</summary>
|
||||
public async ValueTask OfferToNodeAsync(NodeId node, CancellationToken ct)
|
||||
{
|
||||
if (ComponentOf(node) is { } component && await GetAsync(component, ct) is { } offer)
|
||||
{
|
||||
await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>请求某个节点更新到它手里那份(验过签的)清单。节点不在线返回 false。</summary>
|
||||
public ValueTask<bool> RequestUpdateAsync(NodeId node, CancellationToken ct) => _gateway.NotifyUpdateApplyAsync(node, ct);
|
||||
|
||||
public void RecordStatus(NodeId node, UpdateStatus? status)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
_status.TryRemove(node.Value, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
_status[node.Value] = status;
|
||||
}
|
||||
}
|
||||
|
||||
public UpdateStatus? StatusOf(NodeId node) => _status.TryGetValue(node.Value, out UpdateStatus? status) ? status : null;
|
||||
|
||||
public void Forget(NodeId node) => _status.TryRemove(node.Value, out _);
|
||||
|
||||
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||
|
||||
private static string? ComponentOf(NodeId node) =>
|
||||
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
|
||||
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
|
||||
: null;
|
||||
|
||||
private static string Key(string component, string part) => $"release.{component}.{part}";
|
||||
|
||||
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
|
||||
{
|
||||
offer = null;
|
||||
problem = null;
|
||||
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes || !Base64().IsMatch(signature))
|
||||
{
|
||||
problem = "manifest or signature has an unexpected size or encoding";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(manifest);
|
||||
JsonElement root = document.RootElement;
|
||||
string component = Text(root, "component");
|
||||
string digest = Text(root, "digest");
|
||||
if (root.ValueKind != JsonValueKind.Object || Array.IndexOf(s_components, component) < 0
|
||||
|| !root.TryGetProperty("seq", out JsonElement seq) || !seq.TryGetInt64(out long seqValue) || seqValue <= 0
|
||||
|| !Digest().IsMatch(digest) || Text(root, "image").Length is 0 or > 200)
|
||||
{
|
||||
problem = "manifest needs a known component, a positive seq, an image and a sha256 digest";
|
||||
return false;
|
||||
}
|
||||
|
||||
offer = new ReleaseOffer(component, seqValue, Text(root, "tag"), Text(root, "version"), digest, manifest, signature);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
problem = "manifest is not valid JSON";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Text(JsonElement root, string name) =>
|
||||
root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9+/=]+$")]
|
||||
private static partial Regex Base64();
|
||||
|
||||
[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
|
||||
private static partial Regex Digest();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>校验失败,带逐字段的原因(键是 JSON 字段路径)。Api 层把它映射成 problem+json 的 <c>errors</c>。</summary>
|
||||
public sealed record ValidationError(string Message, IReadOnlyDictionary<string, string> Fields)
|
||||
: FleetError(MoShiroErrors.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));
|
||||
}
|
||||
Reference in new issue
Block a user