mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-03 01:23:43 +00:00
refactor(servers): simplify latency details
This commit is contained in:
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user