- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
365 lines
14 KiB
Swift
365 lines
14 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class FleetViewModel: ObservableObject {
|
|
struct Row: Equatable, Identifiable {
|
|
let id: UUID
|
|
var instance: Instance
|
|
var status: ConnectionStatus
|
|
var deviceName: String?
|
|
var phoneNumber: String?
|
|
var operatorName: String?
|
|
var networkTechnology: String?
|
|
var signalPercent: Int?
|
|
var cpuPercent: Double?
|
|
var memoryPercent: Double?
|
|
var cpuCount: Int?
|
|
var temperatureCelsius: Double?
|
|
var batteryPercent: Double?
|
|
var ipAddress: String?
|
|
var latencyMilliseconds: Int?
|
|
var connectivity: String?
|
|
var firmwareVersion: String?
|
|
var totalStorageBytes: Int64?
|
|
var availableStorageBytes: Int64?
|
|
var downloadRateBytesPerSecond: Double?
|
|
var uploadRateBytesPerSecond: Double?
|
|
var messagesToday: Int?
|
|
var uptimeSeconds: Double?
|
|
var chainValues: DeviceLinkState?
|
|
var testing: Bool
|
|
/// Why the last probe ended where it did, for the detail text.
|
|
var probeError: UpstreamError?
|
|
/// When the last probe finished. The list shows how old a reading is, which in relay mode is
|
|
/// the difference between "online" meaning now and meaning forty seconds ago.
|
|
var checkedAt: Date?
|
|
|
|
/// Which path this node is reached by. The badge exists because without it "this one is
|
|
/// slow" has no answer that a user can act on.
|
|
var channel: InstanceChannel { instance.endpoint.channel }
|
|
|
|
var freshness: SnapshotFreshness {
|
|
testing ? .unknown : SnapshotFreshness.classify(age: checkedAt.map { Date().timeIntervalSince($0) })
|
|
}
|
|
|
|
var storageUsageRatio: Double? {
|
|
guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else {
|
|
return nil
|
|
}
|
|
return Double(max(0, total - available)) / Double(total)
|
|
}
|
|
|
|
init(
|
|
id: UUID,
|
|
instance: Instance,
|
|
status: ConnectionStatus = .unknown,
|
|
testing: Bool = true
|
|
) {
|
|
self.id = id
|
|
self.instance = instance
|
|
self.status = status
|
|
self.testing = testing
|
|
}
|
|
}
|
|
|
|
@Published var rows: [Row] = []
|
|
@Published var searchText = ""
|
|
@Published var statusFilter: ConnectionStatus?
|
|
@Published var selectedTagFilters: Set<String> = [] {
|
|
didSet {
|
|
let values = Array(selectedTagFilters).sorted()
|
|
preferences.set(values, forKey: "fleet.selectedTagFilters")
|
|
}
|
|
}
|
|
@Published var selectedIDs: Set<UUID> = []
|
|
@Published var selectionMode = false
|
|
@Published var isLoading = false
|
|
@Published private(set) var lastRefresh: Date?
|
|
@Published var errorMessage: String?
|
|
@Published var showingForm = false
|
|
@Published var showingTagBatch = false
|
|
|
|
private let repository: any InstanceRepository
|
|
private let probe: ConnectionProbe
|
|
private let credentials: any CredentialStore
|
|
private let deviceService: (any DeviceSummaryService)?
|
|
private let preferences: UserDefaults
|
|
|
|
init(
|
|
repository: any InstanceRepository,
|
|
probe: ConnectionProbe,
|
|
credentials: any CredentialStore,
|
|
deviceService: (any DeviceSummaryService)? = nil,
|
|
preferences: UserDefaults = .standard
|
|
) {
|
|
self.repository = repository
|
|
self.probe = probe
|
|
self.credentials = credentials
|
|
self.deviceService = deviceService
|
|
self.preferences = preferences
|
|
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
|
|
}
|
|
|
|
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
|
|
func pruneTagFilters() {
|
|
guard selectedTagFilters.isEmpty == false else { return }
|
|
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
|
|
if live != selectedTagFilters {
|
|
selectedTagFilters = live
|
|
}
|
|
}
|
|
|
|
func refresh() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
|
|
let instances = try await repository.list(query)
|
|
rows = instances.map {
|
|
Row(id: $0.id, instance: $0, status: .unknown, testing: true)
|
|
}
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for instance in instances {
|
|
group.addTask { [probe, deviceService] in
|
|
let result = try? await probe.test(instance)
|
|
let summary = try? await deviceService?.summary(instance)
|
|
await self.applyProbe(instance.id, result, summary)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
errorMessage = "无法刷新节点列表"
|
|
}
|
|
pruneTagFilters()
|
|
lastRefresh = Date()
|
|
isLoading = false
|
|
}
|
|
|
|
@discardableResult
|
|
func add(_ input: InstanceInput) async throws -> Instance {
|
|
let created = try await repository.create(input)
|
|
if let password = input.password, !password.isEmpty {
|
|
try credentials.set(instanceID: created.id, password: password)
|
|
}
|
|
showingForm = false
|
|
await refresh()
|
|
return created
|
|
}
|
|
|
|
func update(_ id: UUID, _ input: InstanceInput) async throws {
|
|
guard let original = rows.first(where: { $0.id == id }) else {
|
|
throw RepositoryError.notFound
|
|
}
|
|
_ = try await repository.update(
|
|
id,
|
|
expectedRevision: original.instance.configRevision,
|
|
InstancePatch(
|
|
name: input.name,
|
|
endpoint: input.endpoint,
|
|
tags: input.tags,
|
|
password: input.password
|
|
)
|
|
)
|
|
if let password = input.password, !password.isEmpty {
|
|
try credentials.set(instanceID: id, password: password)
|
|
}
|
|
showingForm = false
|
|
await refresh()
|
|
}
|
|
|
|
func delete(_ ids: [UUID]) async {
|
|
for id in ids {
|
|
if let row = rows.first(where: { $0.id == id }) {
|
|
do {
|
|
try await repository.delete(id, expectedRevision: row.instance.configRevision)
|
|
try credentials.remove(instanceID: id)
|
|
} catch {
|
|
errorMessage = "删除 \(row.instance.name) 失败"
|
|
}
|
|
}
|
|
}
|
|
selectedIDs.removeAll()
|
|
selectionMode = false
|
|
await refresh()
|
|
}
|
|
|
|
func deleteSelected() async {
|
|
await delete(Array(selectedIDs))
|
|
}
|
|
|
|
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
|
|
func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async {
|
|
let normalized = Self.normalizedTag(tag)
|
|
guard normalized.isEmpty == false else { return }
|
|
var failures = 0
|
|
for id in ids {
|
|
guard let row = rows.first(where: { $0.id == id }) else { continue }
|
|
var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized }
|
|
if removing == false {
|
|
updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
do {
|
|
_ = try await repository.update(
|
|
id,
|
|
expectedRevision: row.instance.configRevision,
|
|
InstancePatch(tags: updated)
|
|
)
|
|
} catch {
|
|
failures += 1
|
|
errorMessage = "更新 \(row.instance.name) 标签失败"
|
|
}
|
|
}
|
|
if failures > 0, errorMessage == nil {
|
|
errorMessage = "部分节点标签更新失败"
|
|
}
|
|
await refresh()
|
|
}
|
|
|
|
func tagNames(in ids: [UUID]) -> [String] {
|
|
let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) })
|
|
return set.sorted { $0.localizedCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
private func applyProbe(
|
|
_ id: UUID,
|
|
_ result: ConnectionResult?,
|
|
_ summary: DeviceSummary?
|
|
) {
|
|
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
|
|
rows[index].status = result?.status ?? .unknown
|
|
rows[index].probeError = result?.error
|
|
rows[index].checkedAt = result?.checkedAt
|
|
if let summary {
|
|
rows[index].deviceName = summary.deviceName
|
|
rows[index].phoneNumber = summary.phoneNumber
|
|
rows[index].operatorName = summary.operatorName
|
|
rows[index].networkTechnology = summary.networkTechnology
|
|
rows[index].signalPercent = summary.signalPercent
|
|
rows[index].cpuPercent = summary.cpuPercent
|
|
rows[index].memoryPercent = summary.memoryPercent
|
|
rows[index].cpuCount = summary.cpuCount
|
|
rows[index].temperatureCelsius = summary.temperatureCelsius
|
|
rows[index].batteryPercent = summary.batteryPercent
|
|
rows[index].ipAddress = summary.ipAddress
|
|
rows[index].latencyMilliseconds = summary.latencyMilliseconds
|
|
rows[index].connectivity = summary.connectivity
|
|
rows[index].firmwareVersion = summary.firmwareVersion
|
|
rows[index].totalStorageBytes = summary.totalStorageBytes
|
|
rows[index].availableStorageBytes = summary.availableStorageBytes
|
|
rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond
|
|
rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond
|
|
rows[index].messagesToday = summary.messagesToday
|
|
rows[index].uptimeSeconds = summary.uptimeSeconds
|
|
rows[index].chainValues = summary.linkState
|
|
}
|
|
if rows[index].latencyMilliseconds == nil {
|
|
rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds
|
|
}
|
|
rows[index].testing = false
|
|
}
|
|
|
|
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
|
|
var visibleRows: [Row] {
|
|
rows.filter { row in
|
|
if let statusFilter, row.status != statusFilter {
|
|
return false
|
|
}
|
|
if selectedTagFilters.isEmpty == false {
|
|
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
|
|
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
|
|
guard needles.isSubset(of: tags) else {
|
|
return false
|
|
}
|
|
}
|
|
guard !searchText.isEmpty else { return true }
|
|
let haystacks: [String] = [
|
|
row.instance.name,
|
|
row.instance.endpoint.displayAddress,
|
|
row.instance.tags.joined(separator: " "),
|
|
row.operatorName ?? "",
|
|
row.phoneNumber ?? "",
|
|
row.networkTechnology ?? "",
|
|
row.deviceName ?? ""
|
|
]
|
|
let strings: [String] = haystacks
|
|
return strings.contains {
|
|
$0.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
.sorted { lhs, rhs in
|
|
let left = severity(lhs.status)
|
|
let right = severity(rhs.status)
|
|
if left != right { return left < right }
|
|
return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private func severity(_ status: ConnectionStatus) -> Int {
|
|
switch status {
|
|
case .offline: return 0
|
|
case .authRequired, .degraded: return 1
|
|
case .unknown: return 2
|
|
case .online: return 3
|
|
}
|
|
}
|
|
|
|
var allTags: [String] {
|
|
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
var tagCounts: [String: Int] {
|
|
var counts: [String: Int] = [:]
|
|
for row in rows {
|
|
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
|
|
counts[tag, default: 0] += 1
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func countForTagFilter(_ tag: String) -> Int {
|
|
tagCounts[Self.normalizedTag(tag)] ?? 0
|
|
}
|
|
|
|
func isTagFilterActive(_ tag: String) -> Bool {
|
|
selectedTagFilters.contains(Self.normalizedTag(tag))
|
|
}
|
|
|
|
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
|
|
func tagCoverage(_ tag: String, in ids: [UUID]) -> Int {
|
|
let needle = Self.normalizedTag(tag)
|
|
guard needle.isEmpty == false else { return 0 }
|
|
let targets = Set(ids)
|
|
return rows.filter { row in
|
|
targets.contains(row.id)
|
|
&& row.instance.tags.map(Self.normalizedTag).contains(needle)
|
|
}.count
|
|
}
|
|
|
|
func toggleTagFilter(_ tag: String) {
|
|
let normalized = Self.normalizedTag(tag)
|
|
guard normalized.isEmpty == false else { return }
|
|
if selectedTagFilters.contains(normalized) {
|
|
selectedTagFilters.remove(normalized)
|
|
} else {
|
|
selectedTagFilters.insert(normalized)
|
|
}
|
|
}
|
|
|
|
func clearTagFilters() {
|
|
guard selectedTagFilters.isEmpty == false else { return }
|
|
selectedTagFilters.removeAll()
|
|
}
|
|
|
|
private static func normalizedTag(_ tag: String) -> String {
|
|
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
}
|
|
}
|
|
|
|
protocol DeviceSummaryService {
|
|
func summary(_ instance: Instance) async throws -> DeviceSummary
|
|
}
|
|
|
|
extension DeviceService: DeviceSummaryService {}
|