refactor(servers): simplify latency details

This commit is contained in:
Qiu Yuzhou
2026-10-27 01:19:35 +08:00
parent e9c13c97b1
commit 9ccee1fc53
6 changed files with 295 additions and 117 deletions
@@ -19,6 +19,7 @@ struct NodeDetailPresentation {
case information
case connection
case pluginParameters
case connectivity
}
struct Properties: Equatable, Identifiable {
@@ -249,3 +250,48 @@ struct NodeDetailPresentation {
return date.formatted(date: .numeric, time: .shortened)
}
}
extension NodeDetailPresentation {
static func connectivityHistory(
_ records: [ServerConnectivity.Record], locale: Locale, timeZone: TimeZone
) -> Properties {
Properties(
id: .connectivity, title: ConnectivityCopy.text("连通性测试"),
items: records.isEmpty
? [Property(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试"))]
: records.map { record in
Property(
label: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone),
value: ConnectivityCopy.outcome(record.outcome))
})
}
static func connectivitySummary(
_ summary: ServerConnectivity.Summary, serverName: (NodeID) -> String,
locale: Locale, timeZone: TimeZone
) -> Properties {
let items: [Property]
if let id = summary.serverID, let record = summary.record {
items = [
Property(
label: ConnectivityCopy.text("响应延迟"), value: ConnectivityCopy.outcome(record.outcome)),
Property(label: ConnectivityCopy.text("服务器"), value: serverName(id)),
Property(
label: ConnectivityCopy.text("测试时间"),
value: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone)),
]
} else {
items = [
Property(
label: ConnectivityCopy.text("状态"),
value: ConnectivityCopy.text(summary.hasResults ? "无成功结果" : "尚未测试"))
]
}
return Properties(id: .connectivity, title: ConnectivityCopy.text("最快响应延迟"), items: items)
}
private static func connectivityTime(_ date: Date, locale: Locale, timeZone: TimeZone) -> String {
date.formatted(
Date.FormatStyle(date: .omitted, time: .standard, locale: locale, timeZone: timeZone))
}
}
@@ -357,6 +357,57 @@
}
}
}
},
"状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Status"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "状态"
}
}
}
},
"服务器": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Server"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "服务器"
}
}
}
},
"测试时间": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Test time"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "测试时间"
}
}
}
}
},
"version": "1.0"
@@ -18,82 +18,15 @@ struct CatalogNodeDetailView: View {
var body: some View {
if let node {
let detail = presentation(for: node)
NodeDetailView(detail: detail) {
Button(
activation.pendingTargetID == nodeID
? String(localized: "激活中…", table: "ServerDetails")
: String(localized: "激活", table: "ServerDetails"),
systemImage: "bolt.fill"
) {
Task { @MainActor in
do {
_ = try await activation.activate(nodeID, via: workflow)
} catch {
// 激活状态记录失败,详情在 feedback 区域显示结果。
}
}
}
.disabled(!(eligibility?.canActivate ?? false) || activation.isPending)
if let subscription = subscriptionSummary(for: node) {
Button(
workflow.refreshingSubscriptionIDs.contains(subscription.id)
? String(localized: "更新中…", table: "ServerDetails")
: String(localized: "更新", table: "ServerDetails"),
systemImage: "arrow.clockwise"
) {
Task { await workflow.refreshSubscription(subscription.id) }
}
.disabled(workflow.refreshingSubscriptionIDs.contains(subscription.id))
}
} secondaryActions: {
Group {
if let module = workflow.connectivity {
ConnectivityTestButton(module: module, node: node)
}
Button(
workflow.isFavorite(nodeID)
? String(localized: "取消收藏", table: "ServerDetails")
: String(localized: "收藏", table: "ServerDetails")
) {
do {
try workflow.setFavorite(nodeID, isFavorite: !workflow.isFavorite(nodeID))
} catch {
errors.present(error)
ObservedConnectivityProperties(
module: module, node: node, serverName: workflow.displayName(for:)
) { properties in
detailView(for: node, connectivity: properties)
}
}
if !node.isGroup && parameters.failure != nil {
Button(String(localized: "重新加载", table: "ServerDetails")) {
parameters.reload(load: workflow.serverDetailPluginOptions)
}
}
} feedback: {
ActivationDetailFeedbackView(
messages: detail.activationMessages,
completion: activation.completion?.targetID == nodeID ? activation.completion : nil)
if let subscription = subscriptionSummary(for: node) {
SubscriptionRefreshFeedbackView(
isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id),
completion: workflow.subscriptionRefreshCompletions[subscription.id]
)
.id(subscription.id)
}
} propertiesFooter: { group in
if group.id == .subscription,
let fraction = subscriptionSummary(for: node)?.information?.usedFraction
{
ProgressView(value: fraction) {
Text("流量消耗")
} currentValueLabel: {
Text(fraction, format: .percent.precision(.fractionLength(0)))
}
.progressViewStyle(.linear)
.tint(.accentColor)
} else {
nil
}
} supplement: {
if let module = workflow.connectivity {
ConnectivityDetailView(module: module, node: node, workflow: workflow)
detailView(for: node)
}
}
.onAppear {
@@ -107,6 +40,91 @@ struct CatalogNodeDetailView: View {
}
}
private func detailView(
for node: CatalogTreeNode, connectivity: NodeDetailPresentation.Properties? = nil
) -> some View {
var detail = presentation(for: node)
if let connectivity { detail.properties.append(connectivity) }
return NodeDetailView(detail: detail) {
primaryActions(for: node)
} secondaryActions: {
if let module = workflow.connectivity {
ConnectivityTestButton(module: module, node: node)
}
Button(
workflow.isFavorite(nodeID)
? String(localized: "取消收藏", table: "ServerDetails")
: String(localized: "收藏", table: "ServerDetails")
) {
do {
try workflow.setFavorite(nodeID, isFavorite: !workflow.isFavorite(nodeID))
} catch {
errors.present(error)
}
}
if !node.isGroup && parameters.failure != nil {
Button(String(localized: "重新加载", table: "ServerDetails")) {
parameters.reload(load: workflow.serverDetailPluginOptions)
}
}
} feedback: {
ActivationDetailFeedbackView(
messages: detail.activationMessages,
completion: activation.completion?.targetID == nodeID ? activation.completion : nil)
if let subscription = subscriptionSummary(for: node) {
SubscriptionRefreshFeedbackView(
isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id),
completion: workflow.subscriptionRefreshCompletions[subscription.id]
)
.id(subscription.id)
}
} propertiesFooter: { group in
if group.id == .subscription,
let fraction = subscriptionSummary(for: node)?.information?.usedFraction
{
ProgressView(value: fraction) {
Text("流量消耗")
} currentValueLabel: {
Text(fraction, format: .percent.precision(.fractionLength(0)))
}
.progressViewStyle(.linear)
.tint(.accentColor)
} else {
nil
}
}
}
@ViewBuilder
private func primaryActions(for node: CatalogTreeNode) -> some View {
Button(
activation.pendingTargetID == nodeID
? String(localized: "激活中…", table: "ServerDetails")
: String(localized: "激活", table: "ServerDetails"),
systemImage: "bolt.fill"
) {
Task { @MainActor in
do {
_ = try await activation.activate(nodeID, via: workflow)
} catch {
// 激活状态记录失败,详情在 feedback 区域显示结果。
}
}
}
.disabled(!(eligibility?.canActivate ?? false) || activation.isPending)
if let subscription = subscriptionSummary(for: node) {
Button(
workflow.refreshingSubscriptionIDs.contains(subscription.id)
? String(localized: "更新中…", table: "ServerDetails")
: String(localized: "更新", table: "ServerDetails"),
systemImage: "arrow.clockwise"
) {
Task { await workflow.refreshSubscription(subscription.id) }
}
.disabled(workflow.refreshingSubscriptionIDs.contains(subscription.id))
}
}
private func presentation(for node: CatalogTreeNode) -> NodeDetailPresentation {
let server = node.isGroup ? nil : workflow.serverDetailPresentation(for: nodeID)
let subscription = subscriptionSummary(for: node)
@@ -129,3 +147,28 @@ struct CatalogNodeDetailView: View {
return workflow.subscriptions.first { $0.groupID == node.id }
}
}
/// 只观察测试资料并生成属性;布局由通用节点详情负责。
private struct ObservedConnectivityProperties<Content: View>: View {
@Environment(\.locale) private var locale
@Environment(\.timeZone) private var timeZone
@ObservedObject var module: ServerConnectivity
let node: CatalogTreeNode
let serverName: (NodeID) -> String
@ViewBuilder let content: (NodeDetailPresentation.Properties) -> Content
var body: some View {
if node.isGroup {
TimelineView(.periodic(from: .now, by: 1)) { context in
content(
NodeDetailPresentation.connectivitySummary(
module.summary(for: node.connectivityServerIDs, at: context.date),
serverName: serverName, locale: locale, timeZone: timeZone))
}
} else {
content(
NodeDetailPresentation.connectivityHistory(
module.history(for: node.id), locale: locale, timeZone: timeZone))
}
}
}
@@ -97,41 +97,3 @@ enum ConnectivityCopy {
}
}
}
struct ConnectivityDetailView: View {
@ObservedObject var module: ServerConnectivity
let node: CatalogTreeNode
let workflow: CatalogWorkflow
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
VStack(alignment: .leading, spacing: 8) {
Text(ConnectivityCopy.text(node.isGroup ? "最快响应延迟" : "连通性测试"))
.font(.headline)
if node.isGroup {
ConnectivityCell(module: module, node: node, workflow: workflow)
let summary = module.summary(for: node.connectivityServerIDs, at: context.date)
if let id = summary.serverID, let record = summary.record {
Text(workflow.displayName(for: id))
Text(timestamp(record, at: context.date)).foregroundStyle(.secondary)
} else if summary.hasResults {
Text(ConnectivityCopy.text("无成功结果")).foregroundStyle(.secondary)
}
} else {
ForEach(module.history(for: node.id)) { record in
HStack {
Text(timestamp(record, at: context.date)).foregroundStyle(.secondary)
Spacer()
Text(ConnectivityCopy.outcome(record.outcome))
}
}
}
}
}
}
private func timestamp(_ record: ServerConnectivity.Record, at date: Date) -> String {
record.finishedAt.formatted(date: .numeric, time: .standard)
+ (record.isExpired(at: date) ? " · " + ConnectivityCopy.text("已过期") : "")
}
}
@@ -2,15 +2,13 @@ import SwiftUI
/// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。
struct NodeDetailView<
PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View,
Supplement: View
PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View
>: View {
let detail: NodeDetailPresentation
@ViewBuilder let primaryActions: PrimaryActions
@ViewBuilder let secondaryActions: SecondaryActions
@ViewBuilder let feedback: Feedback
let propertiesFooter: (NodeDetailPresentation.Properties) -> PropertiesFooter?
@ViewBuilder let supplement: Supplement
var body: some View {
ScrollView {
@@ -69,7 +67,6 @@ struct NodeDetailView<
}
}
}
supplement
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
@@ -107,14 +107,93 @@ struct NodeDetailPresentationTests {
EmptyView()
} propertiesFooter: { group in
group.id == .information ? FooterMarker() : nil
} supplement: {
EmptyView()
})
host.frame = NSRect(x: 0, y: 0, width: 480, height: 600)
host.layoutSubtreeIfNeeded()
#expect(containsFooter(in: host))
}
@Test
func connectivityHistoryUsesTimeOnlyAndPreservesRepeatedLabelsAndFailures() {
let date = Date(timeIntervalSince1970: 37_421)
let records = [
ServerConnectivity.Record(
finishedAt: date, outcome: .success(milliseconds: 42), networkExpired: true),
ServerConnectivity.Record(finishedAt: date, outcome: .requestTimeout),
ServerConnectivity.Record(finishedAt: date.addingTimeInterval(-60), outcome: .http(302)),
]
let properties = NodeDetailPresentation.connectivityHistory(
records, locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 3600)!)
#expect(properties.id == .connectivity)
#expect(properties.items.map(\.label) == ["11:23:41", "11:23:41", "11:22:41"])
#expect(properties.items.map(\.value) == ["42 ms", ConnectivityCopy.text("请求超时"), "HTTP 302"])
}
@Test
func connectivityEmptyStatesAreExplicitProperties() {
let locale = Locale(identifier: "en_GB")
let zone = TimeZone(secondsFromGMT: 0)!
let history = NodeDetailPresentation.connectivityHistory([], locale: locale, timeZone: zone)
#expect(
history.items == [
.init(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试"))
])
for hasResults in [false, true] {
let summary = ServerConnectivity.Summary(
serverID: nil, record: nil, hasResults: hasResults, isTesting: true)
let properties = NodeDetailPresentation.connectivitySummary(
summary,
serverName: { _ in
Issue.record("No server should be resolved")
return ""
},
locale: locale, timeZone: zone)
#expect(
properties.items == [
.init(
label: ConnectivityCopy.text("状态"),
value: ConnectivityCopy.text(hasResults ? "无成功结果" : "尚未测试"))
])
}
}
@Test
func connectivityGroupPropertiesFollowFreshnessNetworkChangesAndNames() async {
let ids = [NodeID.fresh(), NodeID.fresh()]
var now = Date(timeIntervalSince1970: 37_421)
var latency = 10
var name = "First"
let module = ServerConnectivity(
configuration: { _ in
SslocalServerDocument(
id: "test", server: "example.com", serverPort: 443, password: "secret",
method: "aes-256-gcm", plugin: nil, pluginOpts: nil)
}, now: { now },
measure: { _, _, _ in .success(milliseconds: latency) })
func properties(at date: Date) -> NodeDetailPresentation.Properties {
NodeDetailPresentation.connectivitySummary(
module.summary(for: ids, at: date), serverName: { $0 == ids[0] ? name : "Second" },
locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 0)!)
}
await module.wait(for: module.test([ids[0]]))
#expect(properties(at: now).items.map(\.value) == ["10 ms", "First", "10:23:41"])
name = "Renamed"
#expect(properties(at: now).items[1].value == "Renamed")
now.addTimeInterval(60)
latency = 20
await module.wait(for: module.test([ids[1]]))
#expect(properties(at: now).items[1].value == "Renamed")
// First expires while Second is still fresh, without another measurement.
#expect(
properties(at: now.addingTimeInterval(240)).items.map(\.value) == [
"20 ms", "Second", "10:24:41",
])
module.networkChanged()
#expect(properties(at: now.addingTimeInterval(240)).items[1].value == "Renamed")
module.setURL(URL(string: "http://example.com/check")!)
#expect(properties(at: now).items.map(\.value) == [ConnectivityCopy.text("尚未测试")])
}
private func containsFooter(in view: NSView) -> Bool {
view.identifier?.rawValue == "properties-footer"
|| view.subviews.contains { containsFooter(in: $0) }