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": {
|
"control_state": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "dialing"
|
"default": "dialing"
|
||||||
}
|
},
|
||||||
|
"control_down_since": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"RawChunkMetaDto": {
|
"RawChunkMetaDto": {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public static class Mappers
|
|||||||
QuicState.Retrying => "retry_wait",
|
QuicState.Retrying => "retry_wait",
|
||||||
QuicState.Idle => "idle",
|
QuicState.Idle => "idle",
|
||||||
_ => "connecting",
|
_ => "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
|
private static string ControlStateName(ControlState state) => state switch
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ public sealed record QuicLineHealthDto(
|
|||||||
string State = "connecting",
|
string State = "connecting",
|
||||||
int RetryAttempt = 0,
|
int RetryAttempt = 0,
|
||||||
long? RetryInMs = null,
|
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);
|
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;
|
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>
|
/// <summary>Latest per-line control and task health reported by dialing nodes. Volatile runtime state, not historical metrics.</summary>
|
||||||
public sealed class LinkRuntime
|
public sealed class LinkRuntime
|
||||||
@@ -20,7 +21,8 @@ public sealed class LinkRuntime
|
|||||||
{
|
{
|
||||||
var key = (dialerId, link.ListenerId, link.IpVersion);
|
var key = (dialerId, link.ListenerId, link.IpVersion);
|
||||||
current.Add(key);
|
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)))
|
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.Control.RetryInMs = null;
|
||||||
old.Task.Quic.State = QuicState.Idle;
|
old.Task.Quic.State = QuicState.Idle;
|
||||||
old.Task.Quic.RetryInMs = null;
|
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 { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { api } from '@shared/api/client'
|
import { api } from '@shared/api/client'
|
||||||
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types'
|
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 { useQuery } from '@shared/hooks'
|
||||||
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
|
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
|
||||||
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
|
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
|
const connectionIssue = !l.online
|
||||||
? 'Agent 离线'
|
? 'Agent 离线'
|
||||||
: l.quic_health?.control_state === 'backoff'
|
: 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'
|
: l.quic_health?.state === 'retry_wait'
|
||||||
? 'QUIC 拨号失败'
|
? 'QUIC 拨号失败'
|
||||||
: !l.dialer_connected
|
: !l.dialer_connected
|
||||||
|
|||||||
@@ -2120,6 +2120,7 @@ export interface components {
|
|||||||
retry_in_ms?: null | number | string;
|
retry_in_ms?: null | number | string;
|
||||||
/** @default dialing */
|
/** @default dialing */
|
||||||
control_state: string;
|
control_state: string;
|
||||||
|
control_down_since?: unknown;
|
||||||
};
|
};
|
||||||
RawChunkMetaDto: {
|
RawChunkMetaDto: {
|
||||||
chunk_id: string;
|
chunk_id: string;
|
||||||
|
|||||||
Reference in new issue
Block a user