web: show how long a line has been out of contact
The Backend now keeps the time since a link's control layer was last up, because retries move the control state in and out of backoff and its own timestamp keeps resetting. The overview exposes it as control_down_since and the card shows it next to the lost-contact notice.
This commit is contained in:
1 parent
5245f03a78
commit
57acecdb86
7 files changed
+41
-8
No files matched your search
@@ -8774,7 +8774,8 @@
|
||||
"control_state": {
|
||||
"type": "string",
|
||||
"default": "dialing"
|
||||
}
|
||||
},
|
||||
"control_down_since": {}
|
||||
}
|
||||
},
|
||||
"RawChunkMetaDto": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OverviewLineDto> Lines);
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record ReportedLink(LinkHealth Health, long AtMs);
|
||||
/// <summary><paramref name="DownSinceMs"/>:控制层从什么时候起一直不是在线(Unix 毫秒);在线时为空。重试会让控制层状态反复进出退避,所以由 Backend 连着记,不用状态自己的时间。</summary>
|
||||
public sealed record ReportedLink(LinkHealth Health, long AtMs, long? DownSinceMs = null);
|
||||
|
||||
/// <summary>Latest per-line control and task health reported by dialing nodes. Volatile runtime state, not historical metrics.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user