Files
MoShiro/backend/src/MoShiro.Backend.Api/Common/Mappers.cs
T

211 lines
11 KiB
C#

using System.Text.Json;
using Fleet.Server;
using MoShiro.Backend.Analysis;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>应用层模型 → REST DTO。public 层的 DTO 只暴露可以公开的字段。</summary>
public static class Mappers
{
public static DateTimeOffset Time(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms);
public static DateTimeOffset? Time(long? ms) => ms is { } v ? DateTimeOffset.FromUnixTimeMilliseconds(v) : null;
public static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Disabled => "disabled",
_ => "pending",
};
public static TestSummaryDto? Summary(TestRecord? test) =>
test is null ? null : new TestSummaryDto(test.Id.Value, test.Plan, test.Status, Time(test.RequestedAtMs), Time(test.FinishedAtMs));
public static SummaryDto ToDto(SummaryResult summary) =>
new(summary.Values, summary.Samples.ToDictionary(s => s.Key, s => checked((long)s.Value)), summary.Coverage);
public static EventDto ToDto(EventRecord e) => new(e.Id, e.AgentId?.Value, e.Kind, Time(e.AtMs), e.Detail);
public static OverviewLineDto ToDto(OverviewLine line) =>
new(
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
public static HostDto ToDto(HostSnapshot h) =>
new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs));
public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs), p.IpVersion);
public static ProbeDto ToDto(ProbeView v) =>
new(
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
public static SeriesDto ToDto(SeriesResult r, bool live) =>
new(
r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded,
r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial),
[.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions, r.Line.IpVersion);
public static TestDto ToDto(TestDetail detail)
{
TestRecord t = detail.Test;
return new TestDto(
t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger,
Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result), t.IpVersion);
}
public static TestDto ToDto(TestRecord t) =>
new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null, t.IpVersion);
public static TestResultDto ToDto(TestResultView r) =>
new(
r.Metrics,
r.Scores.ToDictionary(s => s.Key, s => new ScoreDto(s.Value.Score, s.Value.Version, s.Value.Estimated, [.. s.Value.Dimensions.Select(d => new DimensionDto(d.Id, d.Label, d.Weight, d.Value, d.Score))], s.Value.Caveats)),
r.Series,
[.. r.Steps.Select(s => new TestStepDto(s.Index, s.TargetMbps, s.LossPct, s.JitterP95Ms, s.DeliveredMbps))],
r.Caveats);
public static AgentDto ToDto(AgentView view)
{
AgentRecord a = view.Agent;
LocalLimitsDto? limits = null;
if (a.LocalLimitsJson is { } json)
{
try
{
LocalLimits? parsed = JsonSerializer.Deserialize(json, ApiJsonContext.Default.LocalLimits);
limits = parsed is null ? null : new LocalLimitsDto(parsed.MaxTotalMbps, parsed.MaxTotalPps, parsed.AllowedTargetCidrs, parsed.MaxConcurrentTests);
}
catch (JsonException)
{
// 无法解析就当作没有上报
}
}
IReadOnlyList<RejectedDto> rejected = [];
if (a.RejectedJson is { } rejectedJson)
{
try
{
rejected = [.. (JsonSerializer.Deserialize(rejectedJson, ApiJsonContext.Default.ListRejectedItem) ?? []).Select(r => new RejectedDto(r.Id, r.Reason))];
}
catch (JsonException)
{
// 同上
}
}
HeartbeatDto? heartbeat = view.Heartbeat is { } h
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
{
Ipv6Diagnostic? diagnostic = view.Heartbeat?.Ipv6;
if (diagnostic is null && view.Agent.Ipv6DiagnosticsJson is { } json)
{
try
{
diagnostic = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic);
}
catch (JsonException)
{
// 旧节点或损坏的快照显示未知,不推断为没有 IPv6
}
}
bool configured = view.Agent.Ipv6Host.Length > 0;
string state = diagnostic?.State ?? "unknown";
if (!configured && state is "ready" or "unknown")
{
state = "address_not_configured";
}
return new Ipv6DiagnosticDto(configured, diagnostic?.SocketSupported ?? view.Agent.Ipv6Supported, diagnostic?.Listening ?? view.Agent.Ipv6Supported,
diagnostic?.HasGlobalAddress, diagnostic?.HasDefaultRoute, state, diagnostic?.NetworkScope ?? "unknown", diagnostic?.HostHasGlobalAddress, diagnostic?.HostHasDefaultRoute);
}
private static Ipv6DiagnosticDto? ProbeIpv6Of(ProbeRecord probe)
{
if (probe.Ipv6DiagnosticsJson is not { } json)
{
return null;
}
try
{
Ipv6Diagnostic? d = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic);
return d is null ? null : new Ipv6DiagnosticDto(null, d.SocketSupported, null, d.HasGlobalAddress, d.HasDefaultRoute, d.State, d.NetworkScope, d.HostHasGlobalAddress, d.HostHasDefaultRoute);
}
catch (JsonException)
{
return null;
}
}
public static TaskDto ToDto(TaskRecord t) =>
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
public static SettingDto ToDto(SettingView s) => new(s.Key, s.Type, s.Value, s.Default, s.Description);
public static UserDto ToDto(UserRecord u) => new(u.Id, u.Username, Time(u.CreatedAtMs));
// ── 目录 ──
public static KindDto ToDto(KindSpec k) =>
new(
k.Id, k.Title, k.Description,
[.. Enum.GetValues<Scope>().Where(s => k.Scopes.HasFlag(s)).Select(s => s.ToString().ToLowerInvariant())],
new BlocksDto(k.Blocks.Transport, k.Blocks.Pacer, k.Blocks.Payload, k.Blocks.Behavior, k.Blocks.Samplers),
[.. k.Params.Select(ToDto)],
[.. k.Streams.Select(s => new StreamDto(s.Name, [.. s.Columns.Select(c => new ColumnDto(c.Name, RawChunkCodec.TypeName(c.Type), c.Enc.ToString().ToLowerInvariant(), c.Nullable, RoleName(c.Role)))]))]);
public static ParamDto ToDto(ParamSpec p) => p switch
{
IntParam i => new ParamDto(i.Name, "int", i.DefaultValue, i.Min, i.Max, i.Unit, null, i.Description),
NumberParam n => new ParamDto(n.Name, "number", n.DefaultValue, n.Min, n.Max, n.Unit, null, n.Description),
BoolParam b => new ParamDto(b.Name, "bool", b.DefaultValue, null, null, null, null, b.Description),
EnumParam e => new ParamDto(e.Name, "enum", e.DefaultValue, null, null, null, e.Values, e.Description),
_ => new ParamDto(p.Name, "unknown", p.DefaultValue, null, null, null, null, p.Description),
};
public static PlanDto ToDto(PlanSpec p) =>
new(p.Id, p.Title, p.Description, [.. p.Phases.Select(ph => new PhaseDto(ph.Kind, ph.DurationSeconds, JsonSerializer.SerializeToElement(ph.Overrides.ToDictionary(o => o.Key, o => o.Value), ApiJsonContext.Default.SettingsPatch)))]);
public static MetricDto ToDto(MetricDef def, MetricExport export) =>
new(
export.Id, def.Id, def.Kind.ToString().ToLowerInvariant(), Metric.StatName(export.Stat), def.Label, def.Description,
export.Unit.ToString().ToLowerInvariant(), def.Better.ToString().ToLowerInvariant(), export.Good, export.Poor, def.Fingerprint, def.From);
public static ModelDto ToDto(IServiceModel m) => new(m.Id, m.Title, m.Version, m.Needs);
private static string? RoleName(ColumnRole role) => role switch
{
ColumnRole.TsProbe => "ts_probe",
ColumnRole.TsAgent => "ts_agent",
ColumnRole.TsLocal => "ts_local",
_ => null,
};
}