- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
548 lines
19 KiB
Swift
548 lines
19 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
|
case none
|
|
case status
|
|
case groups
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .none: return "平铺"
|
|
case .status: return "状态"
|
|
case .groups: return "分组"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FleetGroup: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let rows: [FleetViewModel.Row]
|
|
}
|
|
|
|
struct FleetGroupHealth: Equatable {
|
|
var total = 0
|
|
var online = 0
|
|
var abnormal = 0
|
|
var testing = 0
|
|
var signalValues: [Int] = []
|
|
var latencyValues: [Int] = []
|
|
var batteryValues: [Double] = []
|
|
var storageValues: [Double] = []
|
|
var trafficValues: [Double] = []
|
|
var onlineValues: [Bool] = []
|
|
var abnormalValues: [Bool] = []
|
|
|
|
private var signalAverage: Double? {
|
|
average(signalValues.map(Double.init))
|
|
}
|
|
|
|
private var latencyAverage: Double? {
|
|
average(latencyValues.map(Double.init))
|
|
}
|
|
|
|
private var batteryAverage: Double? {
|
|
average(batteryValues)
|
|
}
|
|
|
|
private var storageAverage: Double? {
|
|
average(storageValues)
|
|
}
|
|
|
|
private var trafficTotal: Double {
|
|
trafficValues.reduce(0, +)
|
|
}
|
|
|
|
var detail: String {
|
|
var parts = ["\(total) 台"]
|
|
if let signal = signalAverage {
|
|
parts.append("信号 \(Int(signal.rounded()))%")
|
|
}
|
|
if let latency = latencyAverage {
|
|
parts.append("延迟 \(Int(latency.rounded())) ms")
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
var signalTint: Color? {
|
|
signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
|
|
}
|
|
|
|
var latencyTint: Color? {
|
|
latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
|
|
}
|
|
|
|
var batteryTint: Color? {
|
|
batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) }
|
|
}
|
|
|
|
var storageTint: Color? {
|
|
storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) }
|
|
}
|
|
|
|
var trafficTint: Color? {
|
|
trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success)
|
|
}
|
|
|
|
var onlineAverageText: String {
|
|
guard onlineValues.isEmpty == false else { return "不可用" }
|
|
let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count)
|
|
return "\(Int((ratio * 100).rounded()))%"
|
|
}
|
|
|
|
var onlineTint: Color? {
|
|
onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted)
|
|
}
|
|
|
|
var abnormalAverageText: String {
|
|
guard abnormalValues.isEmpty == false else { return "不可用" }
|
|
let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count)
|
|
return "\(Int((ratio * 100).rounded()))%"
|
|
}
|
|
|
|
var abnormalTint: Color? {
|
|
abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success)
|
|
}
|
|
|
|
private func average(_ values: [Double]) -> Double? {
|
|
guard values.isEmpty == false else { return nil }
|
|
return values.reduce(0, +) / Double(values.count)
|
|
}
|
|
|
|
var signalAverageText: String {
|
|
signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用"
|
|
}
|
|
|
|
var latencyAverageText: String {
|
|
latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用"
|
|
}
|
|
|
|
var storageAverageText: String {
|
|
storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用"
|
|
}
|
|
|
|
var trafficTotalText: String {
|
|
guard trafficValues.isEmpty == false else { return "不可用" }
|
|
return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s"
|
|
}
|
|
}
|
|
|
|
@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
|
|
|
|
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 groupMode: FleetGroupMode = .groups {
|
|
didSet {
|
|
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
|
}
|
|
}
|
|
@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 showingGroupManager = false
|
|
@Published private(set) var groups: [InstanceGroup] = []
|
|
|
|
private let repository: any InstanceRepository
|
|
private let probe: ConnectionProbe
|
|
private let credentials: any CredentialStore
|
|
private let deviceService: (any DeviceSummaryService)?
|
|
private let groupStore: (any InstanceGroupStore)?
|
|
private let preferences: UserDefaults
|
|
|
|
init(
|
|
repository: any InstanceRepository,
|
|
probe: ConnectionProbe,
|
|
credentials: any CredentialStore,
|
|
deviceService: (any DeviceSummaryService)? = nil,
|
|
groupStore: (any InstanceGroupStore)? = nil,
|
|
preferences: UserDefaults = .standard
|
|
) {
|
|
self.repository = repository
|
|
self.probe = probe
|
|
self.credentials = credentials
|
|
self.deviceService = deviceService
|
|
self.groupStore = groupStore
|
|
self.preferences = preferences
|
|
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
|
|
let storedMode = preferences.string(forKey: "fleet.groupMode")
|
|
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
|
|
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
|
|
await loadGroups()
|
|
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,
|
|
origin: input.origin,
|
|
tags: input.tags,
|
|
groupId: input.groupId,
|
|
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))
|
|
}
|
|
|
|
@discardableResult
|
|
func createGroup(named name: String) async throws -> InstanceGroup {
|
|
guard let groupStore else { throw RepositoryError.notFound }
|
|
let group = try await groupStore.create(InstanceGroupInput(name: name))
|
|
await loadGroups()
|
|
return group
|
|
}
|
|
|
|
func updateGroup(_ id: UUID, name: String) async throws {
|
|
guard let groupStore else { return }
|
|
_ = try await groupStore.update(id, name)
|
|
await loadGroups()
|
|
}
|
|
|
|
func deleteGroup(_ id: UUID) async throws {
|
|
guard let groupStore else { return }
|
|
try await groupStore.delete(id)
|
|
for row in rows where row.instance.groupId == id {
|
|
_ = try await repository.update(
|
|
row.instance.id,
|
|
expectedRevision: row.instance.configRevision,
|
|
InstancePatch(groupId: nil)
|
|
)
|
|
}
|
|
await loadGroups()
|
|
await refresh()
|
|
}
|
|
|
|
func assign(groupID: UUID?, to ids: [UUID]) async throws {
|
|
var failures = 0
|
|
for id in ids {
|
|
guard let row = rows.first(where: { $0.id == id }) else { continue }
|
|
do {
|
|
_ = try await repository.update(
|
|
id,
|
|
expectedRevision: row.instance.configRevision,
|
|
InstancePatch(groupId: groupID)
|
|
)
|
|
} catch {
|
|
failures += 1
|
|
errorMessage = "更新 \(row.instance.name) 分组失败"
|
|
}
|
|
}
|
|
if failures > 0 {
|
|
throw RepositoryError.databaseFailed
|
|
}
|
|
await refresh()
|
|
}
|
|
|
|
func loadGroups() async {
|
|
guard let groupStore else { return }
|
|
groups = (try? await groupStore.list()) ?? []
|
|
}
|
|
|
|
func groupName(for id: UUID?) -> String? {
|
|
guard let id else { return nil }
|
|
return groups.first { $0.id == id }?.name
|
|
}
|
|
|
|
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
|
|
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.origin.absoluteString,
|
|
row.instance.tags.joined(separator: " "),
|
|
row.operatorName ?? "",
|
|
row.phoneNumber ?? "",
|
|
row.networkTechnology ?? "",
|
|
row.deviceName ?? ""
|
|
]
|
|
let strings: [String] = haystacks
|
|
return strings.contains {
|
|
$0.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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()
|
|
}
|
|
|
|
var groupedRows: [FleetGroup] {
|
|
let rows = visibleRows.sorted {
|
|
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
|
|
}
|
|
switch groupMode {
|
|
case .none:
|
|
return [FleetGroup(id: "", title: "", rows: rows)]
|
|
case .status:
|
|
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
|
|
return order.compactMap { status in
|
|
let matched = rows.filter { $0.status == status }
|
|
guard !matched.isEmpty else { return nil }
|
|
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
|
|
}
|
|
case .groups:
|
|
var groupedRows: [UUID: [Row]] = [:]
|
|
var ungroupedRows: [Row] = []
|
|
for row in rows {
|
|
if let id = row.instance.groupId, groupName(for: id) != nil {
|
|
groupedRows[id, default: []].append(row)
|
|
} else {
|
|
ungroupedRows.append(row)
|
|
}
|
|
}
|
|
let namedGroups = groupedRows.map { key, values in
|
|
FleetGroup(
|
|
id: key.uuidString,
|
|
title: groupName(for: key) ?? "未命名分组",
|
|
rows: values
|
|
)
|
|
}
|
|
.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
|
|
return namedGroups + (ungroupedRows.isEmpty
|
|
? []
|
|
: [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)])
|
|
}
|
|
}
|
|
|
|
func health(for group: FleetGroup) -> FleetGroupHealth {
|
|
FleetGroupHealth(
|
|
total: group.rows.count,
|
|
online: group.rows.filter { $0.status == .online }.count,
|
|
abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count,
|
|
testing: group.rows.filter(\.testing).count,
|
|
signalValues: group.rows.compactMap(\.signalPercent),
|
|
latencyValues: group.rows.compactMap(\.latencyMilliseconds),
|
|
batteryValues: group.rows.compactMap(\.batteryPercent),
|
|
storageValues: group.rows.compactMap(\.storageUsageRatio),
|
|
trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond)
|
|
+ group.rows.compactMap(\.uploadRateBytesPerSecond)
|
|
,
|
|
onlineValues: group.rows.map { $0.status == .online },
|
|
abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown }
|
|
)
|
|
}
|
|
}
|
|
|
|
protocol DeviceSummaryService {
|
|
func summary(_ instance: Instance) async throws -> DeviceSummary
|
|
}
|
|
|
|
extension DeviceService: DeviceSummaryService {}
|