diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index bd3c0c4..d1780d5 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -8774,7 +8774,8 @@ "control_state": { "type": "string", "default": "dialing" - } + }, + "control_down_since": {} } }, "RawChunkMetaDto": { diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index ab7d070..e5da580 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -53,7 +53,7 @@ public static class Mappers QuicState.Retrying => "retry_wait", QuicState.Idle => "idle", _ => "connecting", - }, quic.RetryAttempt, quic.RetryInMs, ControlStateName(link.Control.State)); + }, quic.RetryAttempt, quic.RetryInMs, ControlStateName(link.Control.State), Time(snapshot.DownSinceMs)); } private static string ControlStateName(ControlState state) => state switch diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 1c91b2a..4994440 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -52,7 +52,8 @@ public sealed record QuicLineHealthDto( string State = "connecting", int RetryAttempt = 0, long? RetryInMs = null, - string ControlState = "dialing"); + string ControlState = "dialing", + DateTimeOffset? ControlDownSince = null); public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList Lines); diff --git a/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs index d97e824..b75ebc8 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs @@ -3,7 +3,8 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -public sealed record ReportedLink(LinkHealth Health, long AtMs); +/// :控制层从什么时候起一直不是在线(Unix 毫秒);在线时为空。重试会让控制层状态反复进出退避,所以由 Backend 连着记,不用状态自己的时间。 +public sealed record ReportedLink(LinkHealth Health, long AtMs, long? DownSinceMs = null); /// Latest per-line control and task health reported by dialing nodes. Volatile runtime state, not historical metrics. public sealed class LinkRuntime @@ -20,7 +21,8 @@ public sealed class LinkRuntime { var key = (dialerId, link.ListenerId, link.IpVersion); current.Add(key); - _links[key] = new ReportedLink(link, atMs); + long? downSince = link.Control.State == ControlState.Up ? null : _links.TryGetValue(key, out ReportedLink? previous) && previous.DownSinceMs is { } since ? since : atMs; + _links[key] = new ReportedLink(link, atMs, downSince); } foreach ((string Dialer, string Listener, string IpVersion) key in _links.Keys.Where(k => k.Dialer == dialerId && !current.Contains(k))) @@ -41,7 +43,7 @@ public sealed class LinkRuntime old.Control.RetryInMs = null; old.Task.Quic.State = QuicState.Idle; old.Task.Quic.RetryInMs = null; - _links[pair.Key] = new ReportedLink(old, atMs); + _links[pair.Key] = new ReportedLink(old, atMs, pair.Value.DownSinceMs ?? atMs); } } } diff --git a/backend/tests/MoShiro.Backend.Tests/LinkRuntimeTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkRuntimeTests.cs new file mode 100644 index 0000000..2c8400b --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/LinkRuntimeTests.cs @@ -0,0 +1,28 @@ +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Tests; + +public sealed class LinkRuntimeTests +{ + private static LinkHealth Health(ControlState state) => new() { ListenerId = "agt_l", Control = { State = state } }; + + [Fact] + public void DownSince_StaysAtTheFirstLostReport_AndClearsWhenTheControlLinkIsUp() + { + var runtime = new LinkRuntime(); + runtime.Replace("agt_d", 1_000, [Health(ControlState.Up)]); + Assert.Null(runtime.Get("agt_d", "agt_l", IpVersions.V4)!.DownSinceMs); + + runtime.Replace("agt_d", 2_000, [Health(ControlState.Backoff)]); + runtime.Replace("agt_d", 3_000, [Health(ControlState.Dialing)]); // 重试期间状态在退避和拨号之间来回,失联的起点不变 + runtime.Replace("agt_d", 4_000, [Health(ControlState.Backoff)]); + Assert.Equal(2_000, runtime.Get("agt_d", "agt_l", IpVersions.V4)!.DownSinceMs); + + runtime.MarkDialerOffline("agt_d", 5_000); + Assert.Equal(2_000, runtime.Get("agt_d", "agt_l", IpVersions.V4)!.DownSinceMs); + + runtime.Replace("agt_d", 6_000, [Health(ControlState.Up)]); + Assert.Null(runtime.Get("agt_d", "agt_l", IpVersions.V4)!.DownSinceMs); + } +} diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index faa10d0..ec94d3a 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types' -import { fmtAgo } from '@shared/format' +import { fmtAgo, fmtDuration } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui' import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live' @@ -196,7 +196,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang const connectionIssue = !l.online ? 'Agent 离线' : l.quic_health?.control_state === 'backoff' - ? '与 Agent 失联' + ? `与 Agent 失联${l.quic_health.control_down_since ? ` ${fmtDuration(Math.max(0, Date.now() - new Date(l.quic_health.control_down_since).getTime()))}` : ''}` : l.quic_health?.state === 'retry_wait' ? 'QUIC 拨号失败' : !l.dialer_connected diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index e8be502..681b289 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -2120,6 +2120,7 @@ export interface components { retry_in_ms?: null | number | string; /** @default dialing */ control_state: string; + control_down_since?: unknown; }; RawChunkMetaDto: { chunk_id: string;