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:
chick
2026-09-13 00:46:26 +08:00
parent 409345cba3
commit 2ff23d1fb0
23 changed files with 3600 additions and 269 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ coverage/
apps/web/dist/
.hermes/
.local-run/
artifacts/
apps/ios/.build/
build/
data/
+11 -30
View File
@@ -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
View File
@@ -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
}
}
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 {
+2 -1
View File
@@ -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"