Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
T

1617 lines
52 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
@State private var editingInstance: Instance?
@State private var collapsedGroups: Set<String> = []
@State private var isSummaryCollapsed = true
@State private var showingGroupEditor = false
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if !model.rows.isEmpty {
organizationControls
fleetSummary
}
if model.isLoading && model.rows.isEmpty {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AISkeletonLine(width: 0.38, height: 17)
HStack(spacing: 10) {
AISkeletonCard(height: 78)
AISkeletonCard(height: 78)
}
AISkeletonLine(width: 0.54, height: 15)
AISkeletonLine(width: 0.72, height: 15)
}
}
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
if model.visibleRows.isEmpty && !model.rows.isEmpty {
AICard {
AIEmptyState(
systemImage: "magnifyingglass",
title: "未找到实例",
message: "换个名称、地址、运营商或标签试试。"
)
}
}
ForEach(model.groupedRows) { group in
instanceGroup(group)
}
if !model.rows.isEmpty && !model.selectionMode {
VStack(spacing: 0) {
Button {
showingGroupEditor = true
} label: {
Label("管理分组与批量归类", systemImage: "folder.badge.gearshape")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
}
if !model.rows.isEmpty {
Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
}
}
.padding(16)
}
.toolbar {
if model.selectionMode {
ToolbarItemGroup(placement: .navigation) {
Button {
model.selectedIDs = model.selectedIDs.count == model.visibleRows.count
? []
: Set(model.visibleRows.map(\.id))
} label: {
Image(systemName: model.selectedIDs.count == model.visibleRows.count ? "circle" : "checkmark.circle")
}
.accessibilityLabel(model.selectedIDs.count == model.visibleRows.count ? "取消全选" : "全选")
.disabled(model.visibleRows.isEmpty)
Button {
model.selectionMode = false
model.selectedIDs.removeAll()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("退出批量选择")
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
showsDeleteDialog = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel(model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count) 个实例")
.disabled(model.selectedIDs.isEmpty)
}
} else {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("管理分组", systemImage: "folder.badge.gearshape") {
model.showingGroupManager = true
}
.disabled(model.isLoading)
AIIconButton("批量选择", systemImage: "checklist") {
model.selectionMode = true
}
.disabled(model.visibleRows.isEmpty)
AIIconButton("添加实例", systemImage: "plus") {
model.showingForm = true
}
.disabled(model.isLoading)
}
}
}
.sheet(isPresented: $model.showingGroupManager) {
InstanceGroupManagerView(model: model)
}
.sheet(isPresented: $showingGroupEditor) {
InstanceGroupEditorView(
model: model,
targetIDs: batchGroupTargetIDs,
onSaved: {
model.selectedIDs.removeAll()
model.selectionMode = false
}
)
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(
model: model,
instance: editingInstance
)
}
.onChange(of: model.showingForm) { showing in
if !showing {
editingInstance = nil
}
}
.background(AITheme.canvas.ignoresSafeArea())
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationTitle("节点总览")
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
}
private extension FleetView {
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
private var updatedDetail: String {
guard let lastRefresh = model.lastRefresh else {
return model.isLoading ? "正在读取" : "待刷新"
}
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))"
}
private var searchField: some View {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
private var organizationControls: some View {
VStack(alignment: .leading, spacing: 8) {
AISegmentedControl(
"组织方式",
selection: $model.groupMode,
options: FleetGroupMode.allCases.map { ($0, $0.title) }
)
}
}
private var fleetOverviewHeader: some View {
Button {
withAnimation(.easeOut(duration: 0.22)) {
isSummaryCollapsed.toggle()
}
} label: {
HStack(spacing: 10) {
Image(systemName: "gauge.with.needle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 22, height: 22)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text("运行概览")
.font(.headline.weight(.semibold))
.kerning(-0.1)
.foregroundStyle(AITheme.ink)
Text(updatedDetail)
.font(.caption2.weight(.medium))
.foregroundStyle(AITheme.muted)
}
Spacer(minLength: 8)
Image(systemName: isSummaryCollapsed ? "chevron.down" : "chevron.up")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 30, height: 30)
.background(AITheme.control)
.clipShape(Circle())
.accessibilityHidden(true)
}
.padding(.vertical, 7)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .contain)
.accessibilityLabel(isSummaryCollapsed ? "展开运行概览" : "折叠运行概览")
.accessibilityAddTraits(.isButton)
}
private var groupedCount: Int {
summaryRows.filter { model.groupName(for: $0.instance.groupId) != nil }.count
}
private var groupCount: Int {
Set(summaryRows.compactMap(\.instance.groupId)).count
}
private var tagCount: Int {
Set(summaryRows.flatMap(\.instance.tags)).count
}
private var summaryRows: [FleetViewModel.Row] { model.visibleRows }
private var batchGroupTargetIDs: [UUID] {
model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted {
$0.uuidString < $1.uuidString
}
}
private var fleetSummary: some View {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
fleetOverviewHeader
HStack(spacing: 10) {
FleetPrimaryStat(
title: "在线率",
value: summaryRows.isEmpty ? "--" : "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%",
detail: "\(onlineCount)/\(summaryRows.count) 台在线",
systemImage: "checkmark.seal.fill",
tint: summaryRows.isEmpty ? AITheme.muted : (onlineCount == summaryRows.count ? AITheme.success : AITheme.warning)
)
FleetPrimaryStat(
title: "异常节点",
value: "\(abnormalCount)",
detail: abnormalCount > 0 ? "需要尽快处理" : "无异常节点",
systemImage: abnormalCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.seal.fill",
tint: abnormalCount > 0 ? AITheme.danger : AITheme.success
)
}
if isSummaryCollapsed {
HStack(spacing: 8) {
FleetHeaderMiniStat(
title: "信号",
value: signalSummary,
tint: signalSummaryTint,
systemImage: "cellularbars"
)
FleetHeaderMiniStat(
title: "存储",
value: storageSummary,
tint: storageSummaryTint,
systemImage: "internaldrive"
)
FleetHeaderMiniStat(
title: "分组",
value: "\(groupCount)",
tint: AITheme.primary,
systemImage: "folder"
)
}
} else {
HStack(alignment: .center, spacing: 10) {
HStack {
Text("节点状态分布")
.font(.caption.weight(.semibold))
.kerning(0.2)
.foregroundStyle(AITheme.muted)
Spacer()
Text("\(summaryRows.count) 台")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
}
Grid(alignment: .leading, horizontalSpacing: 7, verticalSpacing: 0) {
GridRow {
ForEach(statusSegments, id: \.status) { segment in
statusSegmentButton(segment)
}
}
}
if model.statusFilter != nil {
Button {
model.statusFilter = nil
} label: {
Label("清除状态筛选", systemImage: "xmark.circle.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(AITheme.primary)
}
.buttonStyle(.plain)
.frame(minHeight: 32)
}
}
}
if !isSummaryCollapsed {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(stride(from: 0, to: fleetSummaryItems.count, by: 2).map {
Array(fleetSummaryItems[$0..<min($0 + 2, fleetSummaryItems.count)])
}, id: \.first?.title) { rowItems in
GridRow {
ForEach(rowItems, id: \.title) { item in
FleetStatCard(
title: item.title,
value: item.value,
systemImage: item.systemImage,
tint: item.tint
)
}
if rowItems.count == 1 {
Color.clear
}
}
}
}
if summaryRows.isEmpty && !model.rows.isEmpty {
Text("当前筛选没有节点")
.font(.footnote.weight(.medium))
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
FleetGroupDistributionStrip(
grouped: groupedCount,
ungrouped: summaryRows.count - groupedCount,
groupCount: groupCount,
tagCount: tagCount
)
}
}
}
}
private func statusSegmentButton(_ segment: FleetStatusSegment) -> some View {
let isSelected = model.statusFilter == segment.status
let tint = statusColor(segment.status)
return Button {
model.statusFilter = isSelected ? nil : segment.status
} label: {
VStack(alignment: .leading, spacing: 6) {
Text("\(segment.count)")
.font(.subheadline.weight(.bold))
.monospacedDigit()
.foregroundStyle(tint)
.frame(maxWidth: .infinity, alignment: .leading)
Capsule(style: .continuous)
.fill(tint)
.frame(height: 4)
}
.padding(.vertical, 9)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 44)
.background(isSelected ? tint.opacity(0.14) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(isSelected ? tint.opacity(0.38) : AITheme.line)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(segment.status.label) \(segment.count) 台")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
}
.buttonStyle(.plain)
}
private struct FleetStatusSegment {
let status: ConnectionStatus
let count: Int
var ratio: Double { max(0, Double(count)) }
}
private struct FleetGroupDistributionStrip: View {
let grouped: Int
let ungrouped: Int
let groupCount: Int
let tagCount: Int
private var total: Int { max(1, grouped + ungrouped) }
private var groupedRatio: CGFloat { CGFloat(grouped) / CGFloat(total) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
DistributionBadge(
title: "分组",
value: "\(groupCount)",
systemImage: "folder",
tint: AITheme.primary
)
DistributionBadge(
title: "标签",
value: "\(tagCount)",
systemImage: "tag",
tint: AITheme.success
)
Spacer(minLength: 8)
Text("已分组 \(grouped)/\(grouped + ungrouped)")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
}
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous)
.fill(AITheme.control)
Capsule(style: .continuous)
.fill(AITheme.primary.opacity(0.30))
.frame(width: max(4, proxy.size.width * groupedRatio))
}
}
.frame(height: 5)
}
.accessibilityElement(children: .combine)
}
}
private struct DistributionBadge: View {
let title: String
let value: String
let systemImage: String
let tint: Color
var body: some View {
HStack(spacing: 5) {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text("\(title) \(value)")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, 9)
.frame(minHeight: 28)
.background(tint.opacity(0.10))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.16))
}
}
}
private var statusSegments: [FleetStatusSegment] {
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
return order.compactMap { status in
let count = summaryRows.filter { $0.status == status }.count
guard count > 0 else { return nil }
return FleetStatusSegment(status: status, count: count)
}
}
private var fleetSummaryItems: [FleetStatItem] {
var items = [
FleetStatItem(
title: "节点状态",
value: "\(onlineCount) 在线 · \(abnormalCount) 异常",
systemImage: "server.rack",
tint: abnormalCount > 0 ? AITheme.warning : AITheme.success
),
FleetStatItem(
title: "检测进度",
value: "\(settledCount)/\(summaryRows.count) 已读取",
systemImage: "checkmark.seal",
tint: testingCount > 0 ? AITheme.warning : AITheme.success
),
FleetStatItem(title: "信号", value: signalSummary, systemImage: "cellularbars", tint: signalSummaryTint),
FleetStatItem(title: "延迟", value: latencySummary, systemImage: "speedometer", tint: latencySummaryTint),
FleetStatItem(title: "存储", value: storageSummary, systemImage: "internaldrive", tint: storageSummaryTint),
FleetStatItem(title: "流量", value: trafficSummary, systemImage: "arrow.up.arrow.down", tint: trafficSummaryTint),
FleetStatItem(title: "CPU", value: cpuSummary, systemImage: "cpu", tint: loadSummaryTint),
FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint)
]
if summaryRows.contains(where: { $0.temperatureCelsius != nil }) {
items.append(
FleetStatItem(
title: "温度",
value: temperatureSummary,
systemImage: "thermometer.medium",
tint: temperatureSummaryTint
)
)
}
if summaryRows.contains(where: { $0.batteryPercent != nil }) {
items.append(
FleetStatItem(
title: "电量",
value: batterySummary,
systemImage: "battery.100",
tint: batterySummaryTint
)
)
}
return items
}
private var onlineCount: Int { summaryRows.filter { $0.status == .online }.count }
private var abnormalCount: Int {
summaryRows.filter { $0.status != .online && $0.status != .unknown }.count
}
private var testingCount: Int { summaryRows.filter(\.testing).count }
private var settledCount: Int { summaryRows.count - testingCount }
private var signalValues: [Int] {
summaryRows.compactMap(\.signalPercent)
}
private var signalSummary: String {
formatAverage(signalValues) { "\($0)%" }
}
private var signalSummaryTint: Color {
colorForAverage(signalValues) { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
}
private var latencyValues: [Int] {
summaryRows.compactMap(\.latencyMilliseconds)
}
private var latencySummary: String {
formatAverage(latencyValues) { "\(Int($0.rounded())) ms" }
}
private var latencySummaryTint: Color {
colorForAverage(latencyValues) { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
}
private var cpuSummary: String {
formatAverage(summaryRows.compactMap(\.cpuPercent)) { "\(Int($0.rounded()))%" }
}
private var memorySummary: String {
formatAverage(summaryRows.compactMap(\.memoryPercent)) { "\(Int($0.rounded()))%" }
}
private var loadSummaryTint: Color {
let values = (summaryRows.compactMap(\.cpuPercent) + summaryRows.compactMap(\.memoryPercent))
return colorForAverage(values) { $0 >= 85 ? AITheme.danger : ($0 >= 65 ? AITheme.warning : AITheme.success) }
}
private var temperatureSummary: String {
formatAverage(summaryRows.compactMap(\.temperatureCelsius)) { "\(Int($0.rounded()))°C" }
}
private var temperatureSummaryTint: Color {
colorForAverage(summaryRows.compactMap(\.temperatureCelsius)) {
$0 >= 75 ? AITheme.danger : ($0 >= 60 ? AITheme.warning : AITheme.success)
}
}
private var batterySummary: String {
formatAverage(summaryRows.compactMap(\.batteryPercent)) { "\(Int($0.rounded()))%" }
}
private var batterySummaryTint: Color {
colorForAverage(summaryRows.compactMap(\.batteryPercent)) {
$0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success)
}
}
private var storageSummary: String {
let pairs = storagePairs
guard pairs.isEmpty == false else { return "不可用" }
let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +)
let total = pairs.map(\.total).reduce(0, +)
guard total > 0 else { return "不可用" }
return "\(Int((Double(used) / Double(total) * 100).rounded()))% 已用"
}
private var storageSummaryTint: Color {
guard storagePairs.isEmpty == false else { return AITheme.muted }
let usedBytes = storagePairs.map { max(0, $0.total - $0.available) }.reduce(0, +)
let totalBytes = storagePairs.map(\.total).reduce(0, +)
let ratio = Double(usedBytes) / Double(max(1, totalBytes))
return colorForRatio(ratio) {
$0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success)
}
}
private var storagePairs: [(total: Int64, available: Int64)] {
summaryRows.compactMap { row in
guard let total = row.totalStorageBytes, let available = row.availableStorageBytes else {
return nil
}
return (total: total, available: available)
}
}
private var trafficSummary: String {
let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond)
guard values.isEmpty == false else { return "不可用" }
return ByteCountFormatter.string(fromByteCount: Int64(values.reduce(0, +)), countStyle: .decimal) + "/s"
}
private var trafficSummaryTint: Color {
let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond)
return colorForAverage(values) {
$0 >= 50_000_000 ? AITheme.warning : AITheme.success
}
}
private func colorForRatio(_ value: Double, transform: (Double) -> Color) -> Color {
transform(max(0, min(1, value)))
}
private func formatAverage(_ values: [Double], transform: (Double) -> String) -> String {
guard values.isEmpty == false else { return "不可用" }
let average = values.reduce(0, +) / Double(values.count)
return transform(average)
}
private func formatAverage(_ values: [Int], transform: (Double) -> String) -> String {
formatAverage(values.map(Double.init), transform: transform)
}
private func colorForAverage(
_ values: [Double],
transform: (Double) -> Color
) -> Color {
guard values.isEmpty == false else { return AITheme.muted }
return transform(values.reduce(0, +) / Double(values.count))
}
private func colorForAverage(
_ values: [Int],
transform: (Double) -> Color
) -> Color {
colorForAverage(values.map(Double.init), transform: transform)
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Circle()
.fill(statusColor(row.status))
.frame(width: 7, height: 7)
.overlay {
Circle()
.strokeBorder(AITheme.line)
}
.accessibilityHidden(true)
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
InlinePill(
title: row.status.label,
systemImage: statusIcon(row.status),
tint: statusColor(row.status)
)
}
Text(row.instance.origin.absoluteString)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
}
FleetStatusStrip(
status: row.status,
statusDetail: row.testing ? "正在读取运行信息" : statusDetail(row)
)
if let health = chainHealth(row) {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible())], spacing: 6) {
ForEach(health) { item in
FleetLinkState(item: item)
}
}
}
if model.groupName(for: row.instance.groupId) != nil || !row.instance.tags.isEmpty {
FlowLayout(spacing: 6) {
if let groupName = model.groupName(for: row.instance.groupId) {
InlinePill(title: groupName, systemImage: "folder", tint: AITheme.primary)
}
ForEach(row.instance.tags, id: \.self) { tag in
InlinePill(title: tag, systemImage: "tag", tint: AITheme.muted)
}
}
}
FleetMetricGrid(metrics: rowMetrics(row))
}
}
.overlay(alignment: .topLeading) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(
systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle"
)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
.frame(width: 44, height: 44)
.background(.ultraThinMaterial, in: Circle())
.overlay {
Circle()
.strokeBorder(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.line)
}
}
.buttonStyle(.plain)
.padding(4)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
}
}
private var summaryColumns: [GridItem] {
[GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
}
private func chainHealth(_ row: FleetViewModel.Row) -> [FleetLinkItem]? {
guard let values = row.chainValues else { return nil }
return [
FleetLinkItem(
title: "硬件",
isAvailable: values.hardwareOnline,
),
FleetLinkItem(
title: "控制",
isAvailable: values.controlOnline,
),
FleetLinkItem(
title: "SIM",
isAvailable: values.simPresent,
),
FleetLinkItem(
title: "蜂窝",
isAvailable: values.cellularOnline,
),
]
}
private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] {
var metrics: [FleetMetric] = [
.init(label: "设备", value: row.deviceName, systemImage: "iphone.gen3"),
.init(label: "运营商", value: row.operatorName, systemImage: "antenna.radiowaves.left.and.right"),
.init(label: "号码", value: row.phoneNumber, systemImage: "simcard"),
.init(label: "制式", value: row.networkTechnology, systemImage: "dot.radiowaves.left.and.right"),
.init(
label: "信号",
value: row.signalPercent.map { "\($0)%" },
systemImage: "cellularbars",
tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
),
.init(label: "IP", value: row.ipAddress, systemImage: "network"),
.init(
label: "延迟",
value: row.latencyMilliseconds.map { "\($0) ms" },
systemImage: "speedometer",
tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
)
]
metrics.append(
contentsOf: [
.init(
label: "CPU",
value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount),
systemImage: "cpu",
tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
),
.init(
label: "内存",
value: row.memoryPercent.map { Self.percentText($0) },
systemImage: "memorychip",
tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
),
.init(
label: "温度",
value: row.temperatureCelsius.map { Self.temperatureText($0) },
systemImage: "thermometer.medium",
tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted
),
.init(
label: "电量",
value: row.batteryPercent.map { Self.percentText($0) },
systemImage: "battery.100",
tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
)
]
)
metrics.append(
contentsOf: [
.init(label: "固件", value: row.firmwareVersion, systemImage: "waveform.path.ecg"),
.init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText), systemImage: "clock"),
.init(
label: "存储",
value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
systemImage: "internaldrive",
tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
),
.init(
label: "下行",
value: row.downloadRateBytesPerSecond.map(Self.rateText),
systemImage: "arrow.down"
),
.init(
label: "上行",
value: row.uploadRateBytesPerSecond.map(Self.rateText),
systemImage: "arrow.up"
),
.init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }, systemImage: "message")
]
)
return metrics.filter(\.hasValue)
}
private static func cpuValue(percent: Double?, cores: Int?) -> String? {
guard percent != nil || cores != nil else { return nil }
let usage = percent.map(Self.percentText) ?? "--"
guard let cores, cores > 0 else { return usage }
return "\(usage) · \(cores) 核"
}
private static func durationText(_ seconds: Double) -> String {
Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
}
private static func rateText(_ bytesPerSecond: Double) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
}
private static func percentText(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0...1))) + "%"
}
private static func temperatureText(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0...1))) + "°C"
}
private func storageColor(_ ratio: Double) -> Color {
ratio >= 0.90 ? AITheme.danger : (ratio >= 0.75 ? AITheme.warning : AITheme.success)
}
private func signalColor(_ value: Int?) -> Color? {
guard let value else { return nil }
return value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func resourceColor(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
}
private func temperatureColor(_ value: Double) -> Color {
value >= 75 ? AITheme.danger : (value >= 60 ? AITheme.warning : AITheme.success)
}
private func batteryColor(_ value: Double) -> Color {
value <= 20 ? AITheme.danger : (value <= 40 ? AITheme.warning : AITheme.success)
}
private func statusDetail(_ row: FleetViewModel.Row) -> String {
let parts = [
row.operatorName,
row.deviceName,
row.connectivity,
row.latencyMilliseconds.map { "延迟 \($0) ms" }
]
.compactMap { $0 }
.filter { !$0.isEmpty }
guard parts.isEmpty == false else {
return row.status == .online ? "连接正常" : "暂无运行信息"
}
return parts.joined(separator: " · ")
}
private func latencyColor(_ milliseconds: Int?) -> Color? {
guard let milliseconds else { return nil }
return milliseconds < 300 ? AITheme.success : (milliseconds < 800 ? AITheme.warning : AITheme.danger)
}
private func groupDetail(_ group: FleetGroup) -> String {
model.health(for: group).detail
}
private func groupTint(_ group: FleetGroup) -> Color {
switch model.groupMode {
case .status:
return group.rows.first.map { statusColor($0.status) } ?? AITheme.muted
case .tags, .groups:
return AITheme.primary
case .none:
return AITheme.muted
}
}
@ViewBuilder
private func instanceGroup(_ group: FleetGroup) -> some View {
if model.groupMode == .none {
ForEach(group.rows) { row in
instanceEntry(row)
}
} else {
Section {
ForEach(group.rows) { row in
if collapsedGroups.contains(group.id) {
EmptyView()
} else {
instanceEntry(row)
}
}
} header: {
let health = model.health(for: group)
FleetGroupHeader(
title: group.title,
detail: groupDetail(group),
onlineValue: health.onlineAverageText,
abnormalValue: health.abnormalAverageText,
onlineTint: health.onlineTint ?? AITheme.muted,
abnormalTint: health.abnormalTint ?? AITheme.muted,
total: group.rows.count,
testing: health.testing,
signalValue: health.signalAverageText,
latencyValue: health.latencyAverageText,
storageValue: health.storageAverageText,
trafficValue: health.trafficTotalText,
signalTint: health.signalTint ?? AITheme.muted,
latencyTint: health.latencyTint ?? AITheme.muted,
storageTint: health.storageTint ?? AITheme.muted,
trafficTint: health.trafficTint ?? AITheme.muted,
tint: groupTint(group),
isCollapsed: collapsedGroups.contains(group.id)
) {
toggleGroup(group.id)
}
.padding(.top, 4)
.background(AITheme.canvas)
}
}
}
private func toggleGroup(_ id: String) {
if collapsedGroups.contains(id) {
collapsedGroups.remove(id)
} else {
collapsedGroups.insert(id)
}
}
private func instanceEntry(_ row: FleetViewModel.Row) -> some View {
Group {
if model.selectionMode {
instanceCard(row)
.contentShape(Rectangle())
} else {
NavigationLink {
InstanceDetailView(
instance: row.instance,
groupName: model.groupName(for: row.instance.groupId)
)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
.contextMenu {
Button {
editingInstance = row.instance
model.showingForm = true
} label: {
Label("编辑实例", systemImage: "pencil")
}
Button(role: .destructive) {
Task { await model.delete([row.id]) }
} label: {
Label("删除实例", systemImage: "trash")
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func statusIcon(_ status: ConnectionStatus) -> String {
switch status {
case .online: return "checkmark.circle"
case .offline: return "xmark.circle"
case .authRequired: return "lock.circle"
case .degraded: return "exclamationmark.triangle"
case .unknown: return "questionmark.circle"
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
} else {
model.selectedIDs.insert(id)
}
}
}
extension FleetView {
private struct FleetMetricGrid: View {
let metrics: [FleetMetric]
var body: some View {
if metrics.isEmpty {
HStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
Text("运行信息等待节点返回")
.font(.caption)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(11)
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} else {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(stride(from: 0, to: metrics.count, by: 2).map { metrics[$0..<min($0 + 2, metrics.count)] }, id: \.first?.label) { rowMetrics in
GridRow {
ForEach(Array(rowMetrics.enumerated()), id: \.offset) { _, metric in
metric
}
if rowMetrics.count == 1 {
Color.clear
.gridCellColumns(1)
}
}
}
}
.padding(10)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct FleetStatItem {
let title: String
let value: String
let systemImage: String
let tint: Color
}
struct FleetStatCard: View {
let title: String
let value: String
let systemImage: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
Text(value)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.72)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(9)
.frame(maxWidth: .infinity, minHeight: 50, alignment: .topLeading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
.accessibilityElement(children: .combine)
}
}
struct FleetPrimaryStat: View {
let title: String
let value: String
let detail: String
let systemImage: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity, alignment: .leading)
Text(detail)
.font(.caption2)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.frame(maxWidth: .infinity, minHeight: 76, alignment: .topLeading)
.background(tint.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous)
.strokeBorder(tint.opacity(0.14))
}
.accessibilityElement(children: .combine)
}
}
struct FleetHeaderMiniStat: View {
let title: String
let value: String
let tint: Color
let systemImage: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Text(value)
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 9)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .topLeading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityElement(children: .combine)
}
}
struct FleetStatusStrip: View {
let status: ConnectionStatus
let statusDetail: String
private var statusTint: Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired, .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(statusTint)
.frame(width: 6, height: 6)
Text(statusDetail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.accessibilityElement(children: .combine)
}
}
struct InlinePill: View {
let title: String
let systemImage: String
let tint: Color
var body: some View {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(tint.opacity(0.10))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.18))
}
}
}
struct FleetLinkItem: Identifiable, Equatable {
let title: String
let isAvailable: Bool?
var id: String { title }
}
struct FleetLinkState: View {
let item: FleetLinkItem
private var tint: Color {
switch item.isAvailable {
case true: return AITheme.success
case false: return AITheme.danger
case nil: return AITheme.muted
}
}
private var stateText: String {
switch item.isAvailable {
case true: return "可用"
case false: return "不可用"
case nil: return "未知"
}
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 5, height: 5)
.accessibilityHidden(true)
Text(stateText)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 7)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, minHeight: 36, alignment: .topLeading)
.background(tint.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(tint.opacity(0.16))
}
.accessibilityElement(children: .combine)
}
}
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 y: CGFloat = 0
var rowHeight: CGFloat = 0
var rowWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
x = 0
y += rowHeight + spacing
rowHeight = 0
rowWidth = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
rowWidth = max(rowWidth, x - spacing)
}
return CGSize(width: rowWidth, height: max(0, y + rowHeight - (rowHeight > 0 ? 0 : spacing)))
}
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)
}
}
}
struct FleetGroupHeader: View {
let title: String
let detail: String
let onlineValue: String
let abnormalValue: String
let onlineTint: Color
let abnormalTint: Color
let total: Int
let testing: Int
let signalValue: String
let latencyValue: String
let storageValue: String
let trafficValue: String
let signalTint: Color
let latencyTint: Color
let storageTint: Color
let trafficTint: Color
let tint: Color
let isCollapsed: Bool
let action: () -> Void
private var onlineRatio: Double {
guard let text = onlineValue.split(separator: "%").first, let value = Double(text) else {
return 0
}
return min(1, max(0, value / 100))
}
private var progressTint: Color {
if testing > 0 { return AITheme.warning }
return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted)
}
private var progress: Double {
onlineRatio
}
var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(tint)
.frame(width: 4, height: 24)
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 24, height: 24)
.accessibilityHidden(true)
}
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
GridRow {
FleetHeaderMetric(
title: "节点",
value: "\(total)",
systemImage: "server.rack",
tint: AITheme.ink
)
FleetHeaderMetric(
title: "在线率",
value: onlineValue,
systemImage: "checkmark.circle",
tint: onlineTint,
placeholder: onlineValue == "不可用"
)
}
GridRow {
FleetHeaderMetric(
title: "异常率",
value: abnormalValue,
systemImage: "exclamationmark.triangle",
tint: abnormalTint,
placeholder: abnormalValue == "不可用"
)
FleetHeaderMetric(
title: "检测中",
value: "\(testing)",
systemImage: "clock.arrow.circlepath",
tint: testing > 0 ? AITheme.warning : AITheme.muted,
placeholder: testing == 0
)
}
}
HStack(spacing: 8) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(progressTint)
.frame(height: 4)
Text("\(onlineRatio.formatted(.number.precision(.fractionLength(0))))%")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(progressTint)
.frame(width: 38, alignment: .trailing)
}
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
GridRow {
FleetHeaderMetric(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint, placeholder: signalValue == "不可用")
FleetHeaderMetric(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint, placeholder: latencyValue == "不可用")
}
GridRow {
FleetHeaderMetric(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint, placeholder: storageValue == "不可用")
FleetHeaderMetric(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint, placeholder: trafficValue == "不可用")
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AITheme.chrome)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(AITheme.line)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title),\(detail)")
.accessibilityHint(isCollapsed ? "展开分组" : "折叠分组")
.accessibilityAddTraits(.isButton)
}
private struct FleetHeaderMetric: View {
let title: String
let value: String
let systemImage: String
let tint: Color
var placeholder = false
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(placeholder ? AITheme.muted : tint)
.accessibilityHidden(true)
Text(title)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
Text(value)
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(placeholder ? AITheme.muted : AITheme.ink)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct FleetMetric: View {
let label: String
let value: String?
let systemImage: String
var tint: Color = AITheme.primary
var hasValue: Bool { value?.isEmpty == false }
var body: some View {
if hasValue {
HStack(spacing: 7) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 16)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
Text(value ?? "暂无")
.font(.caption.weight(.medium))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
}
}