web: show how long a line has been out of contact
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/e2e Pipeline failed

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:
justme committed 2026-10-21 17:16:09 +08:00
1 parent 5245f03a78
commit 57acecdb86
7 files changed
+41 -8

No files matched your search

+2 -1
View File
@@ -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 -2
View File
@@ -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
+1
View File
@@ -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;