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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user