fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid - Add link-chain state to fleet cards and parse hardware/control/SIM/cellular - Replace the wrapped icon with a full-bleed app icon - Bump the IPA build number
This commit is contained in:
+1
-1
@@ -8,6 +8,6 @@ coverage/
|
||||
apps/web/dist/
|
||||
.hermes/
|
||||
.local-run/
|
||||
artifacts/
|
||||
apps/ios/.build/
|
||||
build/
|
||||
data/
|
||||
|
||||
+11
-30
@@ -1,42 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="background" cx="0.22" cy="0.10" r="1.08">
|
||||
<stop offset="0" stop-color="#303945"/>
|
||||
<stop offset="0.42" stop-color="#141921"/>
|
||||
<stop offset="1" stop-color="#050709"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="gold" x1="0.10" y1="0.02" x2="0.90" y2="0.98">
|
||||
<stop offset="0" stop-color="#FFE7AD"/>
|
||||
<linearGradient id="gold" x1="0.18" y1="0.04" x2="0.82" y2="0.96">
|
||||
<stop offset="0" stop-color="#FFE9B0"/>
|
||||
<stop offset="0.24" stop-color="#F4C767"/>
|
||||
<stop offset="0.66" stop-color="#D79F47"/>
|
||||
<stop offset="1" stop-color="#B67D33"/>
|
||||
<stop offset="0.68" stop-color="#DA9F45"/>
|
||||
<stop offset="1" stop-color="#B77B2D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="goldEdge" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF8DE" stop-opacity="0.82"/>
|
||||
<stop offset="0.48" stop-color="#E9BA6D" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#7C5525" stop-opacity="0.72"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0.15" y1="0" x2="0.85" y2="0.82">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/>
|
||||
<stop offset="0.46" stop-color="#FFFFFF" stop-opacity="0.05"/>
|
||||
<linearGradient id="sheen" x1="0.18" y1="0" x2="0.82" y2="0.88">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.24"/>
|
||||
<stop offset="0.52" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#242A33"/>
|
||||
<stop offset="1" stop-color="#0C0F13"/>
|
||||
<stop offset="0" stop-color="#2A2F37"/>
|
||||
<stop offset="1" stop-color="#111419"/>
|
||||
</linearGradient>
|
||||
<filter id="tileShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#000000" flood-opacity="0.48"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#background)"/>
|
||||
<g filter="url(#tileShadow)">
|
||||
<rect x="162" y="162" width="700" height="700" rx="210" fill="url(#gold)"/>
|
||||
</g>
|
||||
<rect x="164" y="164" width="696" height="696" rx="208" fill="url(#sheen)"/>
|
||||
<rect x="164" y="164" width="696" height="696" rx="208" fill="none" stroke="url(#goldEdge)" stroke-width="7"/>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#gold)"/>
|
||||
<rect width="1024" height="1024" fill="url(#sheen)"/>
|
||||
<g fill="url(#bar)">
|
||||
<rect x="317" y="602" width="108" height="188" rx="54"/>
|
||||
<rect x="458" y="475" width="108" height="315" rx="54"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
+11
-30
@@ -1,42 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="background" cx="0.22" cy="0.10" r="1.08">
|
||||
<stop offset="0" stop-color="#303945"/>
|
||||
<stop offset="0.42" stop-color="#141921"/>
|
||||
<stop offset="1" stop-color="#050709"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="gold" x1="0.10" y1="0.02" x2="0.90" y2="0.98">
|
||||
<stop offset="0" stop-color="#FFE7AD"/>
|
||||
<linearGradient id="gold" x1="0.18" y1="0.04" x2="0.82" y2="0.96">
|
||||
<stop offset="0" stop-color="#FFE9B0"/>
|
||||
<stop offset="0.24" stop-color="#F4C767"/>
|
||||
<stop offset="0.66" stop-color="#D79F47"/>
|
||||
<stop offset="1" stop-color="#B67D33"/>
|
||||
<stop offset="0.68" stop-color="#DA9F45"/>
|
||||
<stop offset="1" stop-color="#B77B2D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="goldEdge" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF8DE" stop-opacity="0.82"/>
|
||||
<stop offset="0.48" stop-color="#E9BA6D" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#7C5525" stop-opacity="0.72"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0.15" y1="0" x2="0.85" y2="0.82">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/>
|
||||
<stop offset="0.46" stop-color="#FFFFFF" stop-opacity="0.05"/>
|
||||
<linearGradient id="sheen" x1="0.18" y1="0" x2="0.82" y2="0.88">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.24"/>
|
||||
<stop offset="0.52" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#242A33"/>
|
||||
<stop offset="1" stop-color="#0C0F13"/>
|
||||
<stop offset="0" stop-color="#2A2F37"/>
|
||||
<stop offset="1" stop-color="#111419"/>
|
||||
</linearGradient>
|
||||
<filter id="tileShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#000000" flood-opacity="0.48"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#background)"/>
|
||||
<g filter="url(#tileShadow)">
|
||||
<rect x="162" y="162" width="700" height="700" rx="210" fill="url(#gold)"/>
|
||||
</g>
|
||||
<rect x="164" y="164" width="696" height="696" rx="208" fill="url(#sheen)"/>
|
||||
<rect x="164" y="164" width="696" height="696" rx="208" fill="none" stroke="url(#goldEdge)" stroke-width="7"/>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#gold)"/>
|
||||
<rect width="1024" height="1024" fill="url(#sheen)"/>
|
||||
<g fill="url(#bar)">
|
||||
<rect x="317" y="602" width="108" height="188" rx="54"/>
|
||||
<rect x="458" y="475" width="108" height="315" rx="54"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -10,6 +10,108 @@ public struct DeviceSnapshot: Equatable, Sendable {
|
||||
public let sections: [DeviceSection]
|
||||
}
|
||||
|
||||
public struct DeviceLinkState: Equatable, Sendable {
|
||||
public init(
|
||||
hardwareOnline: Bool? = nil,
|
||||
controlOnline: Bool? = nil,
|
||||
simPresent: Bool? = nil,
|
||||
cellularOnline: Bool? = nil
|
||||
) {
|
||||
self.hardwareOnline = hardwareOnline
|
||||
self.controlOnline = controlOnline
|
||||
self.simPresent = simPresent
|
||||
self.cellularOnline = cellularOnline
|
||||
}
|
||||
|
||||
public var hardwareOnline: Bool?
|
||||
public var controlOnline: Bool?
|
||||
public var simPresent: Bool?
|
||||
public var cellularOnline: Bool?
|
||||
}
|
||||
|
||||
extension DeviceService {
|
||||
static var placeholderSections: [DeviceSection] {
|
||||
[
|
||||
DeviceSection(id: "device", title: "设备", values: [:]),
|
||||
DeviceSection(id: "sim", title: "SIM", values: [:]),
|
||||
DeviceSection(id: "network", title: "蜂窝网络", values: [:]),
|
||||
DeviceSection(id: "stats", title: "系统资源", values: [:]),
|
||||
DeviceSection(id: "cpu", title: "CPU", values: [:]),
|
||||
DeviceSection(id: "connectivity", title: "连接状态", values: [:])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceSummary: Equatable, Sendable {
|
||||
public init(
|
||||
deviceName: String? = nil,
|
||||
phoneNumber: String? = nil,
|
||||
operatorName: String? = nil,
|
||||
networkTechnology: String? = nil,
|
||||
signalPercent: Int? = nil,
|
||||
cpuPercent: Double? = nil,
|
||||
memoryPercent: Double? = nil,
|
||||
cpuCount: Int? = nil,
|
||||
temperatureCelsius: Double? = nil,
|
||||
batteryPercent: Double? = nil,
|
||||
ipAddress: String? = nil,
|
||||
latencyMilliseconds: Int? = nil,
|
||||
connectivity: String? = nil,
|
||||
firmwareVersion: String? = nil,
|
||||
totalStorageBytes: Int64? = nil,
|
||||
availableStorageBytes: Int64? = nil,
|
||||
downloadRateBytesPerSecond: Double? = nil,
|
||||
uploadRateBytesPerSecond: Double? = nil,
|
||||
messagesToday: Int? = nil,
|
||||
uptimeSeconds: Double? = nil,
|
||||
linkState: DeviceLinkState = DeviceLinkState()
|
||||
) {
|
||||
self.deviceName = deviceName
|
||||
self.phoneNumber = phoneNumber
|
||||
self.operatorName = operatorName
|
||||
self.networkTechnology = networkTechnology
|
||||
self.signalPercent = signalPercent
|
||||
self.cpuPercent = cpuPercent
|
||||
self.memoryPercent = memoryPercent
|
||||
self.cpuCount = cpuCount
|
||||
self.temperatureCelsius = temperatureCelsius
|
||||
self.batteryPercent = batteryPercent
|
||||
self.ipAddress = ipAddress
|
||||
self.latencyMilliseconds = latencyMilliseconds
|
||||
self.connectivity = connectivity
|
||||
self.firmwareVersion = firmwareVersion
|
||||
self.totalStorageBytes = totalStorageBytes
|
||||
self.availableStorageBytes = availableStorageBytes
|
||||
self.downloadRateBytesPerSecond = downloadRateBytesPerSecond
|
||||
self.uploadRateBytesPerSecond = uploadRateBytesPerSecond
|
||||
self.messagesToday = messagesToday
|
||||
self.uptimeSeconds = uptimeSeconds
|
||||
self.linkState = linkState
|
||||
}
|
||||
|
||||
public var deviceName: String?
|
||||
public var phoneNumber: String?
|
||||
public var operatorName: String?
|
||||
public var networkTechnology: String?
|
||||
public var signalPercent: Int?
|
||||
public var cpuPercent: Double?
|
||||
public var memoryPercent: Double?
|
||||
public var cpuCount: Int?
|
||||
public var temperatureCelsius: Double?
|
||||
public var batteryPercent: Double?
|
||||
public var ipAddress: String?
|
||||
public var latencyMilliseconds: Int?
|
||||
public var connectivity: String?
|
||||
public var firmwareVersion: String?
|
||||
public var totalStorageBytes: Int64?
|
||||
public var availableStorageBytes: Int64?
|
||||
public var downloadRateBytesPerSecond: Double?
|
||||
public var uploadRateBytesPerSecond: Double?
|
||||
public var messagesToday: Int?
|
||||
public var uptimeSeconds: Double?
|
||||
public var linkState: DeviceLinkState
|
||||
}
|
||||
|
||||
public enum DeviceServiceError: Error, Equatable {
|
||||
case unauthorized
|
||||
case invalidResponse
|
||||
|
||||
@@ -47,6 +47,34 @@ public struct DeviceService: Sendable {
|
||||
return DeviceSnapshot(sections: sections)
|
||||
}
|
||||
|
||||
public func summary(_ instance: Instance) async throws -> DeviceSummary {
|
||||
let routes: [(id: String, route: UpstreamRoute)] = [
|
||||
("device", .device),
|
||||
("sim", .sim),
|
||||
("network", .network),
|
||||
("stats", .stats),
|
||||
("cpu", .statsCpu),
|
||||
("connectivity", .connectivity)
|
||||
]
|
||||
|
||||
var sections: [(String, [String: String])] = []
|
||||
var successful = 0
|
||||
for item in routes {
|
||||
do {
|
||||
let response = try await request(item.route, instance)
|
||||
let values = try Self.flatten(Self.dataObject(response.data))
|
||||
sections.append((item.id, values))
|
||||
successful += 1
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
guard successful > 0 else {
|
||||
throw DeviceServiceError.invalidResponse
|
||||
}
|
||||
return Self.summary(from: sections)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
@@ -86,6 +114,92 @@ public struct DeviceService: Sendable {
|
||||
return root
|
||||
}
|
||||
|
||||
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
|
||||
func key(_ sectionID: String, _ names: [String]) -> String? {
|
||||
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
|
||||
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
func number(_ text: String?) -> Double? {
|
||||
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
|
||||
return Double(text)
|
||||
}
|
||||
|
||||
func bool(_ text: String?) -> Bool? {
|
||||
switch text?.lowercased() {
|
||||
case "是", "true", "yes", "1": return true
|
||||
case "否", "false", "no", "0": return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
let linkState = DeviceLinkState(
|
||||
hardwareOnline: bool(key("device", ["powered"])),
|
||||
controlOnline: bool(key("device", ["online"])),
|
||||
simPresent: sections.contains { $0.0 == "sim" },
|
||||
cellularOnline: key("network", ["registration_status"])?
|
||||
.lowercased()
|
||||
.hasPrefix("registered")
|
||||
)
|
||||
|
||||
return DeviceSummary(
|
||||
deviceName: key("device", ["model", "name", "device"]),
|
||||
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
|
||||
operatorName: key("network", ["operator_name", "operator", "carrier"]),
|
||||
networkTechnology: key(
|
||||
"network",
|
||||
["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
),
|
||||
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
|
||||
.flatMap { Int($0.rounded()) },
|
||||
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
|
||||
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
|
||||
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
|
||||
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
|
||||
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
|
||||
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
|
||||
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
|
||||
Int($0.rounded())
|
||||
},
|
||||
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
|
||||
firmwareVersion: key("device", ["revision", "firmware_version"]),
|
||||
totalStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
|
||||
),
|
||||
availableStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
|
||||
),
|
||||
downloadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
|
||||
),
|
||||
uploadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
|
||||
),
|
||||
messagesToday: number(
|
||||
key("connectivity", ["messages_today", "messages", "sms_today"])
|
||||
).flatMap { Int($0.rounded()) },
|
||||
uptimeSeconds: number(
|
||||
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
|
||||
) ?? number(key("stats", ["uptime_uptime_seconds"]))
|
||||
?? number(key("stats", ["system_uptime_seconds"])),
|
||||
linkState: linkState
|
||||
)
|
||||
}
|
||||
|
||||
private static func byteCount(_ text: String?) -> Int64? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Int64(value.rounded())
|
||||
}
|
||||
|
||||
private static func byteRate(_ text: String?) -> Double? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
|
||||
var output: [String: String] = [:]
|
||||
flatten(source, prefix: "", into: &output, remaining: maximum)
|
||||
|
||||
@@ -5,13 +5,16 @@ final class DeviceViewModel: ObservableObject {
|
||||
@Published var snapshot: DeviceSnapshot?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var connectionStatus: ConnectionStatus = .unknown
|
||||
|
||||
let instance: Instance
|
||||
private let service: DeviceService
|
||||
private let probe: ConnectionProbe?
|
||||
|
||||
init(instance: Instance, service: DeviceService) {
|
||||
init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) {
|
||||
self.instance = instance
|
||||
self.service = service
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -24,6 +27,13 @@ final class DeviceViewModel: ObservableObject {
|
||||
} catch {
|
||||
errorMessage = "无法加载设备数据。"
|
||||
}
|
||||
guard let probe else {
|
||||
connectionStatus = .offline
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
let result = try? await probe.test(instance)
|
||||
connectionStatus = result?.status ?? .offline
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@ import SwiftUI
|
||||
struct InstanceDetailView: View {
|
||||
@StateObject private var model: DeviceViewModel
|
||||
@State private var revealsSensitive = false
|
||||
@State private var showsSensitiveInfo = false
|
||||
|
||||
init(instance: Instance, model: DeviceViewModel? = nil) {
|
||||
init(
|
||||
instance: Instance,
|
||||
groupName: String? = nil,
|
||||
model: DeviceViewModel? = nil
|
||||
) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||||
self.groupName = groupName
|
||||
}
|
||||
|
||||
private let groupName: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
header
|
||||
VStack(spacing: 12) {
|
||||
headerCard
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
@@ -35,56 +43,460 @@ struct InstanceDetailView: View {
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showsSensitiveInfo) {
|
||||
SensitiveValueSheet(
|
||||
values: sensitiveValues,
|
||||
isRevealed: $revealsSensitive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
AICard(padding: 18) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: "cpu")
|
||||
.font(.system(size: 21, weight: .semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 46, height: 46)
|
||||
.background(AITheme.primary.opacity(0.11))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
private var headerCard: some View {
|
||||
AICard(padding: 16, emphasis: true) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(model.instance.name)
|
||||
.font(.title3.weight(.bold))
|
||||
.kerning(-0.2)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(model.instance.name)
|
||||
.font(.title3.weight(.bold))
|
||||
.kerning(-0.2)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.caption)
|
||||
DetailMetricGrid(metrics: headerMetrics)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("数据模块")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中")
|
||||
.font(.caption.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) {
|
||||
ForEach(stride(from: 0, to: moduleSections.count, by: 2).map {
|
||||
Array(moduleSections[$0..<min($0 + 2, moduleSections.count)])
|
||||
}, id: \.first?.id) { rowSections in
|
||||
GridRow {
|
||||
ForEach(rowSections, id: \.id) { section in
|
||||
moduleIndicator(
|
||||
section.title,
|
||||
isAvailable: section.values.isEmpty == false
|
||||
)
|
||||
}
|
||||
if rowSections.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AITag(revealsSensitive ? "敏感值已显示" : "敏感值已隐藏", tint: AITheme.primary)
|
||||
if let availableCount {
|
||||
AITag("\(availableCount)/6 模块", tint: AITheme.success)
|
||||
if groupName != nil || !model.instance.tags.isEmpty {
|
||||
FlowLayout(spacing: 7) {
|
||||
if let groupName {
|
||||
AITag("分组 · \(groupName)", tint: AITheme.primary)
|
||||
}
|
||||
ForEach(model.instance.tags, id: \.self) { tag in
|
||||
AITag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String?
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
let progress: Double?
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private var headerMetrics: [HeaderMetric] {
|
||||
let loading = model.snapshot == nil
|
||||
let phoneValue = stringValue(
|
||||
section: "sim",
|
||||
keys: ["phone_numbers", "phone_number"]
|
||||
)
|
||||
let firmwareValue = stringValue(
|
||||
section: "device",
|
||||
keys: ["revision", "firmware_version"]
|
||||
)
|
||||
let storageValue = storageSummary
|
||||
let uptimeValue = stringValue(
|
||||
section: "stats",
|
||||
keys: ["uptime_seconds", "uptime", "system_uptime_seconds"]
|
||||
).flatMap(durationValue)
|
||||
let signal = summaryValue(
|
||||
section: "network",
|
||||
keys: ["signal_percent", "signal_strength"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { signalTint($0) }
|
||||
)
|
||||
let latency = summaryValue(
|
||||
section: "connectivity",
|
||||
keys: ["latency_ms", "latency"],
|
||||
format: loading ? nil : { "\(Int($0.rounded())) ms" },
|
||||
progress: loading ? nil : { _ in 1 },
|
||||
tint: loading ? nil : { latencyTint($0) }
|
||||
)
|
||||
let cpu = summaryValue(
|
||||
section: "cpu",
|
||||
keys: ["cpu_percent", "cpu_load", "usage"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { resourceTint($0) }
|
||||
)
|
||||
let memory = summaryValue(
|
||||
section: "stats",
|
||||
keys: ["memory_percent", "usage"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { resourceTint($0) }
|
||||
)
|
||||
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
|
||||
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
|
||||
let networkValue = stringValue(
|
||||
section: "network",
|
||||
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
)
|
||||
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
|
||||
|
||||
return [
|
||||
HeaderMetric(
|
||||
title: "连接状态",
|
||||
value: loading ? "--" : model.connectionStatus.label,
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
|
||||
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
|
||||
),
|
||||
HeaderMetric(
|
||||
title: "号码",
|
||||
value: loading ? "--" : phoneValue ?? "无数据",
|
||||
systemImage: "simcard",
|
||||
tint: loading ? AITheme.muted : AITheme.ink,
|
||||
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
|
||||
),
|
||||
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
|
||||
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
|
||||
HeaderMetric(
|
||||
title: "CPU",
|
||||
value: loading ? "--" : cpu?.value ?? "无数据",
|
||||
systemImage: "cpu",
|
||||
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : cpu?.progress
|
||||
),
|
||||
HeaderMetric(
|
||||
title: "内存",
|
||||
value: loading ? "--" : memory?.value ?? "无数据",
|
||||
systemImage: "memorychip",
|
||||
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : memory?.progress
|
||||
),
|
||||
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
|
||||
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
|
||||
]
|
||||
}
|
||||
|
||||
private func makeMetric(
|
||||
title: String,
|
||||
value: String?,
|
||||
systemImage: String,
|
||||
tint: Color?,
|
||||
progress: Double? = nil
|
||||
) -> HeaderMetric {
|
||||
HeaderMetric(title: title, value: value, systemImage: systemImage, tint: tint, progress: progress)
|
||||
}
|
||||
|
||||
private var storageSummary: String {
|
||||
guard let total = numericValue(section: "stats", keys: [
|
||||
"disk_total_bytes", "storage_total_bytes", "total_bytes"
|
||||
]), let available = numericValue(section: "stats", keys: [
|
||||
"disk_available_bytes", "storage_available_bytes", "available_bytes"
|
||||
]), total > 0, available >= 0, total >= available else {
|
||||
return "无数据"
|
||||
}
|
||||
return "\(Int((Double(total - available) / Double(total) * 100).rounded()))% 已用"
|
||||
}
|
||||
|
||||
private var storageTint: Color {
|
||||
if storageRatio == nil { return AITheme.ink }
|
||||
return resourceTint(storageRatio! * 100)
|
||||
}
|
||||
|
||||
private var storageProgress: Double {
|
||||
storageRatio ?? 0
|
||||
}
|
||||
|
||||
private var storageRatio: Double? {
|
||||
guard let total = numericValue(section: "stats", keys: [
|
||||
"disk_total_bytes", "storage_total_bytes", "total_bytes"
|
||||
]), let available = numericValue(section: "stats", keys: [
|
||||
"disk_available_bytes", "storage_available_bytes", "available_bytes"
|
||||
]), total > 0, available >= 0, total >= available else {
|
||||
return nil
|
||||
}
|
||||
return Double(total - available) / Double(total)
|
||||
}
|
||||
|
||||
private func numericValue(
|
||||
section: String,
|
||||
keys: [String]
|
||||
) -> Int64? {
|
||||
stringValue(section: section, keys: keys)
|
||||
.flatMap { Double($0.replacingOccurrences(of: "%", with: "")) }
|
||||
.flatMap { Int64($0.rounded()) }
|
||||
}
|
||||
|
||||
private func durationValue(_ text: String) -> String? {
|
||||
guard let seconds = Double(text.replacingOccurrences(of: "%", with: "")), seconds >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Duration.seconds(seconds).formatted(
|
||||
.units(allowed: [.days, .hours, .minutes], width: .abbreviated)
|
||||
)
|
||||
}
|
||||
|
||||
private func statusColor(_ value: ConnectionStatus) -> Color {
|
||||
switch value {
|
||||
case .online: return AITheme.success
|
||||
case .degraded, .authRequired: return AITheme.warning
|
||||
case .offline: return AITheme.danger
|
||||
case .unknown: return AITheme.muted
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormattedMetric {
|
||||
let value: String
|
||||
let tint: Color
|
||||
let progress: Double
|
||||
}
|
||||
|
||||
private func stringValue(
|
||||
section: String,
|
||||
keys: [String]
|
||||
) -> String? {
|
||||
guard let values = model.snapshot?.sections.first(where: { $0.id == section })?.values else {
|
||||
return nil
|
||||
}
|
||||
return keys.lazy.compactMap { values[$0] }.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func summaryValue(
|
||||
section: String,
|
||||
keys: [String],
|
||||
format: ((Double) -> String)?,
|
||||
progress: ((Double) -> Double)?,
|
||||
tint: ((Double) -> Color)?
|
||||
) -> FormattedMetric? {
|
||||
guard let snapshot = model.snapshot,
|
||||
let values = snapshot.sections.first(where: { $0.id == section })?.values
|
||||
else { return nil }
|
||||
|
||||
let raw = keys.compactMap { values[$0] }.first { !$0.isEmpty }
|
||||
guard let raw, let number = Double(raw.replacingOccurrences(of: "%", with: "")), number >= 0 else {
|
||||
return nil
|
||||
}
|
||||
guard let format, let progress, let tint else { return nil }
|
||||
return FormattedMetric(
|
||||
value: format(number),
|
||||
tint: tint(number),
|
||||
progress: progress(number)
|
||||
)
|
||||
}
|
||||
|
||||
private func signalTint(_ value: Double) -> Color {
|
||||
value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func latencyTint(_ value: Double) -> Color {
|
||||
value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func resourceTint(_ value: Double) -> Color {
|
||||
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
|
||||
}
|
||||
|
||||
private struct DetailHeaderMetric: View {
|
||||
let metric: HeaderMetric
|
||||
|
||||
var title: String { metric.title }
|
||||
var value: String? { metric.value }
|
||||
var systemImage: String { metric.systemImage }
|
||||
var tint: Color { metric.tint ?? AITheme.muted }
|
||||
var progress: Double { metric.progress ?? 0 }
|
||||
var isProgressVisible: Bool {
|
||||
metric.title == "连接状态" || metric.progress != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
if isProgressVisible {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(tint)
|
||||
.frame(height: 4)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 4)
|
||||
}
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.72)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Capsule(style: .continuous)
|
||||
.fill(AITheme.line.opacity(0.55))
|
||||
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailMetricGrid: View {
|
||||
let metrics: [HeaderMetric]
|
||||
|
||||
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
|
||||
private let valueKeys: Set<String> = [
|
||||
"设备", "运营商", "网络", "IP", "固件", "存储", "运行时间"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
let primary = metrics.filter { primaryKeys.contains($0.title) }
|
||||
let values = metrics.filter { valueKeys.contains($0.title) }
|
||||
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
ForEach(primary) { DetailHeaderMetric(metric: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
revealsSensitive.toggle()
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
ForEach(
|
||||
stride(from: 0, to: values.count, by: 2).map {
|
||||
Array(values[$0..<min($0 + 2, values.count)])
|
||||
},
|
||||
id: \.first?.id
|
||||
) { rowMetrics in
|
||||
GridRow {
|
||||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||||
if rowMetrics.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||||
systemImage: revealsSensitive ? "eye.slash" : "eye"
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact, fixedWidth: 112))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if model.isLoading {
|
||||
AILoadingCard("正在读取设备详情")
|
||||
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isAvailable ? AITheme.success : AITheme.control)
|
||||
.frame(width: 6, height: 6)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(isAvailable ? AITheme.ink : AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > 0, x + size.width > maxWidth {
|
||||
x = 0
|
||||
height += size.height + spacing
|
||||
}
|
||||
x += size.width + spacing
|
||||
height = max(height, size.height)
|
||||
}
|
||||
|
||||
return CGSize(width: max(0, x - spacing), height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > bounds.minX, x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +506,20 @@ struct InstanceDetailView: View {
|
||||
return snapshot.sections.filter { !$0.values.isEmpty }.count
|
||||
}
|
||||
|
||||
private var moduleSections: [DeviceSection] {
|
||||
model.snapshot?.sections ?? DeviceService.placeholderSections
|
||||
}
|
||||
|
||||
private var sensitiveValues: [(title: String, value: String)] {
|
||||
guard let snapshot = model.snapshot else { return [] }
|
||||
return snapshot.sections.flatMap { section in
|
||||
section.values
|
||||
.filter { sensitiveKeys.contains($0.key) }
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { (fieldLabel($0.key), $0.value) }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionCard(_ section: DeviceSection) -> some View {
|
||||
if section.values.isEmpty {
|
||||
@@ -117,7 +543,7 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 18) {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
|
||||
Divider()
|
||||
@@ -306,4 +732,79 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
return suffixes.contains { keys.contains($0) }
|
||||
}
|
||||
|
||||
private struct SensitiveValueSheet: View {
|
||||
let values: [(title: String, value: String)]
|
||||
@Binding var isRevealed: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if values.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "lock.open",
|
||||
title: "暂无敏感值",
|
||||
message: "设备数据加载后,这里会集中显示 IMEI、ICCID 等信息。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Toggle(isOn: $isRevealed) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("显示敏感值")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("离开页面或手动关闭后自动隐藏。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.tint(AITheme.primary)
|
||||
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
}
|
||||
AIKeyValueRow(
|
||||
item.title,
|
||||
value: isRevealed ? item.value : masked(item.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("敏感值")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private func masked(_ value: String) -> String {
|
||||
guard let first = value.first, value.count > 6 else {
|
||||
return String(repeating: "•", count: min(value.count, 8))
|
||||
}
|
||||
let tail = value.suffix(4)
|
||||
return "\(first) •••• \(tail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,136 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case tags
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .tags: 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 {
|
||||
@@ -6,34 +138,93 @@ final class FleetViewModel: ObservableObject {
|
||||
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 = {
|
||||
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
|
||||
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
|
||||
}() {
|
||||
didSet {
|
||||
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@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)?
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
groupStore: (any InstanceGroupStore)? = nil
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.groupStore = groupStore
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -42,25 +233,51 @@ final class FleetViewModel: ObservableObject {
|
||||
}
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for instance in instances {
|
||||
group.addTask { [probe] in
|
||||
group.addTask { [probe, deviceService] in
|
||||
let result = try? await probe.test(instance)
|
||||
await self.applyProbe(instance.id, result)
|
||||
let summary = try? await deviceService?.summary(instance)
|
||||
await self.applyProbe(instance.id, result, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "无法刷新节点列表"
|
||||
}
|
||||
lastRefresh = Date()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func add(_ input: InstanceInput) async throws {
|
||||
@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 {
|
||||
@@ -83,9 +300,195 @@ final class FleetViewModel: ObservableObject {
|
||||
await delete(Array(selectedIDs))
|
||||
}
|
||||
|
||||
private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
|
||||
@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
|
||||
}
|
||||
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 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 .tags:
|
||||
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
|
||||
let grouped = tags.map { tag in
|
||||
FleetGroup(
|
||||
id: tag,
|
||||
title: tag,
|
||||
rows: rows.filter { $0.instance.tags.contains(tag) }
|
||||
)
|
||||
}
|
||||
let untagged = rows.filter { $0.instance.tags.isEmpty }
|
||||
return grouped + (untagged.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
|
||||
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 {}
|
||||
|
||||
@@ -3,13 +3,24 @@ import SwiftUI
|
||||
struct InstanceFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let instance: Instance?
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedGroupId: UUID?
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
init(model: FleetViewModel, instance: Instance? = nil) {
|
||||
self.model = model
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
|
||||
_selectedGroupId = State(initialValue: instance?.groupId)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
@@ -24,7 +35,13 @@ struct InstanceFormView: View {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
field("标签", text: $tagsText, placeholder: "home, primary")
|
||||
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
|
||||
groupPicker
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
@@ -56,13 +73,37 @@ struct InstanceFormView: View {
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("添加实例")
|
||||
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var groupPicker: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
AIFieldLabel("分组")
|
||||
Spacer()
|
||||
Button {
|
||||
model.showingGroupManager = true
|
||||
} label: {
|
||||
Label("管理", systemImage: "folder.badge.gearshape")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 72))
|
||||
}
|
||||
Picker("分组", selection: $selectedGroupId) {
|
||||
Text("未分组").tag(UUID?.none)
|
||||
ForEach(model.groups) { group in
|
||||
Text(group.name).tag(UUID?.some(group.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
@@ -111,11 +152,16 @@ struct InstanceFormView: View {
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
groupId: selectedGroupId,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await model.add(input)
|
||||
if let instance {
|
||||
try await model.update(instance.id, input)
|
||||
} else {
|
||||
try await model.add(input)
|
||||
}
|
||||
} catch {
|
||||
validationMessage = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceGroupEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let targetIDs: [UUID]
|
||||
let onSaved: () -> Void
|
||||
|
||||
@State private var selectedGroupID: UUID?
|
||||
@State private var isCreatingGroup = false
|
||||
@State private var newGroupName = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var selectedCount: Int { targetIDs.count }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle(
|
||||
"批量归类",
|
||||
systemImage: "folder.badge.gearshape",
|
||||
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
|
||||
)
|
||||
Text("选择一个分组后保存,所选实例会立即移入该分组。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if isCreatingGroup {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新分组名称")
|
||||
TextField("例如:家里、外场、测试机", text: $newGroupName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 8) {
|
||||
VStack(spacing: 2) {
|
||||
groupRow(
|
||||
title: "未分组",
|
||||
subtitle: "移出当前分组",
|
||||
systemImage: "square.dashed",
|
||||
value: nil,
|
||||
isSelected: selectedGroupID == nil,
|
||||
selectionTrait: selectedGroupID == nil
|
||||
)
|
||||
|
||||
ForEach(model.groups) { group in
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.55))
|
||||
groupRow(
|
||||
title: group.name,
|
||||
subtitle: "\(instanceCount(group.id)) 个实例",
|
||||
systemImage: "folder",
|
||||
value: group.id,
|
||||
isSelected: selectedGroupID == group.id,
|
||||
selectionTrait: selectedGroupID == group.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("实例分组")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("新建") {
|
||||
selectedGroupID = nil
|
||||
isCreatingGroup = true
|
||||
errorMessage = nil
|
||||
}
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Label(
|
||||
selectedGroupID == nil ? "移出分组" : "移入分组",
|
||||
systemImage: "tray.and.arrow.down"
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
value: UUID?,
|
||||
isSelected: Bool,
|
||||
selectionTrait: Bool
|
||||
) -> some View {
|
||||
Button {
|
||||
selectedGroupID = value
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(
|
||||
systemName: isSelected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle.dashed"
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 54)
|
||||
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title),\(subtitle)")
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
if isCreatingGroup {
|
||||
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let group = try await model.createGroup(named: name)
|
||||
selectedGroupID = group.id
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
}
|
||||
|
||||
try await model.assign(
|
||||
groupID: selectedGroupID,
|
||||
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
|
||||
)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = isCreatingGroup
|
||||
? "创建分组失败,名称不能为空且不能重复。"
|
||||
: "保存分组失败,请检查节点后重试。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceGroupManagerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var newName = ""
|
||||
@State private var editingGroup: InstanceGroup?
|
||||
@State private var editingName = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isWorking = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新增分组")
|
||||
TextField("例如:家里、外场、测试机", text: $newName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
if model.groups.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "folder.badge.plus",
|
||||
title: "暂无分组",
|
||||
message: "创建分组后,实例可按业务或位置归类。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(model.groups) { group in
|
||||
AICard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AITheme.primary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(group.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text("\(instanceCount(group.id)) 个实例")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
editingGroup = group
|
||||
editingName = group.name
|
||||
validationMessage = nil
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await delete(group)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
|
||||
.disabled(instanceCount(group.id) > 0 || isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("分组管理")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await create() }
|
||||
} label: {
|
||||
Label("创建分组", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.alert("编辑分组", isPresented: Binding(
|
||||
get: { editingGroup != nil },
|
||||
set: { if !$0 { editingGroup = nil } }
|
||||
)) {
|
||||
TextField("分组名称", text: $editingName)
|
||||
Button("保存") {
|
||||
Task { await saveEditing() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("分组名称最多 100 个字符。")
|
||||
}
|
||||
.task {
|
||||
await model.loadGroups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.createGroup(named: newName)
|
||||
newName = ""
|
||||
} catch {
|
||||
validationMessage = "创建失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func saveEditing() async {
|
||||
guard let editingGroup else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.updateGroup(editingGroup.id, name: editingName)
|
||||
self.editingGroup = nil
|
||||
} catch {
|
||||
validationMessage = "更新失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func delete(_ group: InstanceGroup) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
do {
|
||||
try await model.deleteGroup(group.id)
|
||||
} catch {
|
||||
validationMessage = "删除失败,请稍后重试。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ struct MultiSimAdminApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
switch tab {
|
||||
case .fleet:
|
||||
NavigationStack { FleetView().aiInlineTitle() }
|
||||
@@ -27,6 +27,8 @@ struct MultiSimAdminApp: App {
|
||||
case .settings:
|
||||
NavigationStack { SettingsView().aiInlineTitle() }
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
AITabBar(selection: $tab)
|
||||
}
|
||||
.tint(AITheme.primary)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
public struct InstanceGroup: Codable, Equatable, Identifiable, Sendable {
|
||||
public var id: UUID
|
||||
public var name: String
|
||||
public var createdAt: Date
|
||||
|
||||
public init(id: UUID, name: String, createdAt: Date) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstanceGroupInput: Sendable {
|
||||
public var name: String
|
||||
|
||||
public init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public protocol InstanceGroupStore: Sendable {
|
||||
func list() async throws -> [InstanceGroup]
|
||||
func create(_ input: InstanceGroupInput) async throws -> InstanceGroup
|
||||
func update(_ id: UUID, _ name: String) async throws -> InstanceGroup
|
||||
func delete(_ id: UUID) async throws
|
||||
}
|
||||
|
||||
public final class FileInstanceGroupStore: @unchecked Sendable, InstanceGroupStore {
|
||||
private struct FileModel: Codable {
|
||||
var groups: [InstanceGroup] = []
|
||||
}
|
||||
|
||||
private let location: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let lock = NSLock()
|
||||
private var fileModel = FileModel()
|
||||
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-group-store")
|
||||
|
||||
public init(location: URL) {
|
||||
self.location = URL(fileURLWithPath: location.path)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
self.encoder = encoder
|
||||
|
||||
lock.withLock {
|
||||
if let data = try? Data(contentsOf: location),
|
||||
let decoded = try? decoder.decode(FileModel.self, from: data) {
|
||||
fileModel = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func list() async throws -> [InstanceGroup] {
|
||||
lock.withLock {
|
||||
fileModel.groups.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
public func create(_ input: InstanceGroupInput) async throws -> InstanceGroup {
|
||||
try await run { model in
|
||||
let name = try Self.normalized(input.name)
|
||||
guard !model.groups.contains(where: { $0.name == name }) else {
|
||||
throw RepositoryError.duplicateOrigin
|
||||
}
|
||||
let group = InstanceGroup(id: UUID(), name: name, createdAt: Date())
|
||||
model.groups.append(group)
|
||||
return group
|
||||
}
|
||||
}
|
||||
|
||||
public func update(_ id: UUID, _ name: String) async throws -> InstanceGroup {
|
||||
try await run { model in
|
||||
let normalized = try Self.normalized(name)
|
||||
guard let index = model.groups.firstIndex(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
guard !model.groups.contains(where: { $0.id != id && $0.name == normalized }) else {
|
||||
throw RepositoryError.duplicateOrigin
|
||||
}
|
||||
model.groups[index].name = normalized
|
||||
return model.groups[index]
|
||||
}
|
||||
}
|
||||
|
||||
public func delete(_ id: UUID) async throws {
|
||||
try await run { model in
|
||||
model.groups.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private func run<T: Sendable>(
|
||||
_ transform: @escaping @Sendable (inout FileModel) throws -> T
|
||||
) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
var model = self.lock.withLock { self.fileModel }
|
||||
let output = try transform(&model)
|
||||
try self.write(model)
|
||||
self.lock.withLock { self.fileModel = model }
|
||||
continuation.resume(returning: output)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ model: FileModel) throws {
|
||||
let directory = location.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try encoder.encode(model).write(to: location, options: [.atomic])
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String) throws -> String {
|
||||
let name = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, name.count <= 100 else {
|
||||
throw RepositoryError.invalidOrigin
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 514 KiB After Width: | Height: | Size: 992 KiB |
@@ -10,7 +10,7 @@
|
||||
<key>CFBundleName</key><string>MultiSimAdmin</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.2.0</string>
|
||||
<key>CFBundleVersion</key><string>2</string>
|
||||
<key>CFBundleVersion</key><string>3</string>
|
||||
<key>LSRequiresIPhoneOS</key><true/>
|
||||
<key>MinimumOSVersion</key><string>16.0</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -26,10 +26,16 @@ public enum ConnectionStatus: String, Equatable, Sendable {
|
||||
|
||||
public struct ConnectionResult: Equatable, Sendable {
|
||||
public let status: ConnectionStatus
|
||||
public let requestLatencyMilliseconds: Int?
|
||||
public let checkedAt: Date
|
||||
|
||||
public init(status: ConnectionStatus, checkedAt: Date) {
|
||||
public init(
|
||||
status: ConnectionStatus,
|
||||
requestLatencyMilliseconds: Int? = nil,
|
||||
checkedAt: Date
|
||||
) {
|
||||
self.status = status
|
||||
self.requestLatencyMilliseconds = requestLatencyMilliseconds
|
||||
self.checkedAt = checkedAt
|
||||
}
|
||||
}
|
||||
@@ -42,21 +48,50 @@ public struct ConnectionProbe: Sendable {
|
||||
}
|
||||
|
||||
public func test(_ instance: Instance) async throws -> ConnectionResult {
|
||||
let startedAt = DispatchTime.now()
|
||||
do {
|
||||
let response = try await client.send(.health, baseURL: instance.origin, cookie: nil)
|
||||
let latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
if response.status == 401 || response.status == 403 {
|
||||
return ConnectionResult(status: .authRequired, checkedAt: Date())
|
||||
return ConnectionResult(
|
||||
status: .authRequired,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
}
|
||||
return ConnectionResult(status: .online, checkedAt: Date())
|
||||
return ConnectionResult(
|
||||
status: .online,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
} catch {
|
||||
let latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
switch error {
|
||||
case UpstreamError.unauthorized:
|
||||
return ConnectionResult(status: .authRequired, checkedAt: Date())
|
||||
return ConnectionResult(
|
||||
status: .authRequired,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
case UpstreamError.timeout, is URLError:
|
||||
return ConnectionResult(status: .offline, checkedAt: Date())
|
||||
return ConnectionResult(
|
||||
status: .offline,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
default:
|
||||
return ConnectionResult(status: .degraded, checkedAt: Date())
|
||||
return ConnectionResult(
|
||||
status: .degraded,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? {
|
||||
let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds
|
||||
let milliseconds = Double(nanoseconds) / 1_000_000
|
||||
return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ enum DefaultContainer {
|
||||
location: documentFileURL("credentials.json")
|
||||
)
|
||||
|
||||
private static let instanceGroups: any InstanceGroupStore = FileInstanceGroupStore(
|
||||
location: documentFileURL("instance-groups.json")
|
||||
)
|
||||
|
||||
private static let logStore: any LogStore = FileLogStore(
|
||||
location: documentFileURL("log-center.json")
|
||||
)
|
||||
@@ -19,7 +23,13 @@ enum DefaultContainer {
|
||||
FleetViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
probe: ConnectionProbe(client: SimAdminClient()),
|
||||
credentials: credentials
|
||||
credentials: credentials,
|
||||
deviceService: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
),
|
||||
groupStore: instanceGroups
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +74,8 @@ enum DefaultContainer {
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
)
|
||||
),
|
||||
probe: ConnectionProbe(client: SimAdminClient())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,27 @@ import SwiftUI
|
||||
public struct AICard<Content: View>: View {
|
||||
private let content: Content
|
||||
private let padding: CGFloat
|
||||
private let emphasis: Bool
|
||||
|
||||
public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) {
|
||||
public init(
|
||||
padding: CGFloat = 16,
|
||||
emphasis: Bool = false,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.padding = padding
|
||||
self.emphasis = emphasis
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(AICard.backgroundGradient)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(padding)
|
||||
.background(
|
||||
AICard.backgroundGradient(
|
||||
raisedTop: emphasis
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.overlay {
|
||||
@@ -22,10 +33,12 @@ public struct AICard<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
private extension AICard {
|
||||
static var backgroundGradient: LinearGradient {
|
||||
extension AICard {
|
||||
fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [AITheme.surfaceRaised, AITheme.surface],
|
||||
colors: raisedTop
|
||||
? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)]
|
||||
: [AITheme.surfaceRaised, AITheme.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
@@ -150,35 +163,49 @@ public struct AIStatCard: View {
|
||||
private let value: String
|
||||
private let detail: String?
|
||||
private let tint: Color
|
||||
private let systemImage: String?
|
||||
|
||||
public init(title: String, value: String, detail: String? = nil, tint: Color) {
|
||||
public init(
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String? = nil,
|
||||
systemImage: String? = nil,
|
||||
tint: Color
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.detail = detail
|
||||
self.systemImage = systemImage
|
||||
self.tint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard(padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
AICard(padding: 12) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +473,7 @@ public struct AIKeyValueRow: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.medium))
|
||||
@@ -455,6 +483,7 @@ public struct AIKeyValueRow: View {
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.85)
|
||||
.textSelection(.enabled)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
@@ -600,8 +629,7 @@ extension View {
|
||||
}
|
||||
|
||||
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
self
|
||||
safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AISkeletonLine: View {
|
||||
let width: CGFloat?
|
||||
let height: CGFloat
|
||||
|
||||
init(width: CGFloat? = nil, height: CGFloat) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init(width fraction: CGFloat, height: CGFloat) {
|
||||
self.init(width: nil, height: height)
|
||||
self.fraction = fraction
|
||||
}
|
||||
|
||||
private var fraction: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fraction {
|
||||
GeometryReader { proxy in
|
||||
Capsule(style: .continuous)
|
||||
.frame(width: max(0, proxy.size.width * fraction))
|
||||
}
|
||||
} else if let width {
|
||||
Capsule(style: .continuous)
|
||||
.frame(width: width)
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.background(AISkeletonFill())
|
||||
}
|
||||
}
|
||||
|
||||
struct AISkeletonCard: View {
|
||||
let height: CGFloat
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.frame(height: height)
|
||||
.background(AISkeletonFill())
|
||||
}
|
||||
}
|
||||
|
||||
private struct AISkeletonFill: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(AITheme.control)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,28 @@ final class DeviceServiceTests: XCTestCase {
|
||||
XCTAssertEqual(snapshot.sections[5].values["state"], "connected")
|
||||
}
|
||||
|
||||
func testBuildsRunningSummaryFromUpstreamSections() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let summary = try await service.summary(instance)
|
||||
|
||||
XCTAssertEqual(summary.deviceName, "SIMBox 8")
|
||||
XCTAssertEqual(summary.phoneNumber, "13800138000")
|
||||
XCTAssertEqual(summary.operatorName, "中国移动")
|
||||
XCTAssertEqual(summary.connectivity, "connected")
|
||||
XCTAssertEqual(summary.firmwareVersion, "2.4.1")
|
||||
XCTAssertEqual(summary.totalStorageBytes, 1024)
|
||||
XCTAssertEqual(summary.availableStorageBytes, 512)
|
||||
XCTAssertEqual(summary.downloadRateBytesPerSecond, 128)
|
||||
XCTAssertEqual(summary.uploadRateBytesPerSecond, 64)
|
||||
XCTAssertEqual(summary.messagesToday, 12)
|
||||
XCTAssertEqual(summary.uptimeSeconds, 86400)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
@@ -43,12 +65,12 @@ private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendabl
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let payload: [UpstreamRoute: String] = [
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true}}"#,
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true,"firmware_version":"2.4.1"}}"#,
|
||||
.sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#,
|
||||
.network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400}}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400},"disk_total_bytes":1024,"disk_available_bytes":512}}"#,
|
||||
.statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#,
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21}}"#
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21,"download":128,"upload":64,"messages_today":12}}"#
|
||||
]
|
||||
guard let json = payload[route] else {
|
||||
throw UpstreamError.notSupported
|
||||
|
||||
@@ -28,6 +28,117 @@ final class FleetViewModelTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
|
||||
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
|
||||
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
|
||||
}
|
||||
|
||||
func testVisibleRowsAndGroupModesUseFilteredResults() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
)
|
||||
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
|
||||
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
|
||||
|
||||
await model.refresh()
|
||||
model.searchText = "home"
|
||||
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
|
||||
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
|
||||
|
||||
model.searchText = ""
|
||||
model.groupMode = .tags
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"])
|
||||
XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1])
|
||||
|
||||
model.groupMode = .status
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
|
||||
}
|
||||
|
||||
func testGroupModeKeepsUngroupedInstancesVisible() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
)
|
||||
try await model.add(InstanceInput(name: "Ungrouped", origin: URL(string: "http://192.168.1.72:3000")!))
|
||||
|
||||
await model.refresh()
|
||||
model.groupMode = .groups
|
||||
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["未分组"])
|
||||
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Ungrouped"])
|
||||
}
|
||||
|
||||
func testStoredGroupsResolveNamesAndCanUpdateInstance() async throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathComponent("groups.json")
|
||||
let groupStore = FileInstanceGroupStore(location: location)
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
groupStore: groupStore
|
||||
)
|
||||
|
||||
let createdGroup = try await groupStore.create(InstanceGroupInput(name: "外场"))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Van",
|
||||
origin: URL(string: "http://192.168.1.80:3000")!,
|
||||
groupId: createdGroup.id
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
model.groupMode = .groups
|
||||
|
||||
XCTAssertEqual(model.groups.map(\.name), ["外场"])
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["外场"])
|
||||
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Van"])
|
||||
|
||||
let row = try XCTUnwrap(model.rows.first)
|
||||
try await model.update(row.id, InstanceInput(
|
||||
name: "Van Pro",
|
||||
origin: row.instance.origin,
|
||||
tags: ["field"],
|
||||
groupId: createdGroup.id
|
||||
))
|
||||
|
||||
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
|
||||
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
|
||||
}
|
||||
|
||||
func testAssignMovesSelectedInstancesIntoAndOutOfGroup() async throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathComponent("groups.json")
|
||||
let groupStore = FileInstanceGroupStore(location: location)
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
groupStore: groupStore
|
||||
)
|
||||
let first = try await model.add(InstanceInput(
|
||||
name: "First",
|
||||
origin: URL(string: "http://192.168.1.90:3000")!
|
||||
))
|
||||
let second = try await model.add(InstanceInput(
|
||||
name: "Second",
|
||||
origin: URL(string: "http://192.168.1.91:3000")!
|
||||
))
|
||||
let group = try await model.createGroup(named: "外场")
|
||||
|
||||
try await model.assign(groupID: group.id, to: [first.id, second.id])
|
||||
XCTAssertEqual(model.rows.map(\.instance.groupId), [group.id, group.id])
|
||||
|
||||
try await model.assign(groupID: nil, to: [first.id])
|
||||
XCTAssertEqual(model.rows.first?.instance.groupId, nil)
|
||||
XCTAssertEqual(model.rows.last?.instance.groupId, group.id)
|
||||
}
|
||||
|
||||
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
|
||||
@@ -153,8 +264,25 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
return instance
|
||||
}
|
||||
|
||||
func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance {
|
||||
throw RepositoryError.notFound
|
||||
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
guard items[index].configRevision == expectedRevision else {
|
||||
throw RepositoryError.revisionConflict
|
||||
}
|
||||
var instance = items[index]
|
||||
if let name = patch.name { instance.name = name }
|
||||
if let origin = patch.origin { instance.origin = origin }
|
||||
if let tags = patch.tags { instance.tags = tags }
|
||||
if patch.groupId != nil || instance.groupId != nil { instance.groupId = patch.groupId }
|
||||
if let password = patch.password {
|
||||
instance.credentialConfigured = !password.isEmpty
|
||||
}
|
||||
instance.updatedAt = Date()
|
||||
instance.configRevision += 1
|
||||
items[index] = instance
|
||||
return instance
|
||||
}
|
||||
|
||||
func delete(_ id: UUID, expectedRevision: Int) async throws {
|
||||
|
||||
@@ -14,7 +14,8 @@ xcodebuild -scheme "$APP_NAME" \
|
||||
-derivedDataPath "$DERIVED_ROOT" \
|
||||
build
|
||||
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
|
||||
|
||||
Reference in New Issue
Block a user