279 lines
8.9 KiB
Swift
279 lines
8.9 KiB
Swift
import SwiftUI
|
|
|
|
struct LogsView: View {
|
|
private enum Tab: Hashable {
|
|
case runtime
|
|
case connections
|
|
case diagnostics
|
|
}
|
|
|
|
@StateObject private var model: LogsViewModel
|
|
@State private var tab: Tab = .runtime
|
|
@State private var showsPruneDialog = false
|
|
|
|
init(model: LogsViewModel? = nil) {
|
|
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AITheme.canvas.ignoresSafeArea()
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
if let errorMessage = model.errorMessage {
|
|
AIStatusBanner(
|
|
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
|
}
|
|
if model.snapshot == nil && model.isLoading {
|
|
AILoadingCard("正在同步日志数据")
|
|
} else {
|
|
stats
|
|
AISegmentedControl(
|
|
"日志视图",
|
|
selection: $tab,
|
|
options: [
|
|
(value: Tab.runtime, title: "运行日志"),
|
|
(value: Tab.connections, title: "连接日志"),
|
|
(value: Tab.diagnostics, title: "设备诊断"),
|
|
]
|
|
)
|
|
|
|
filterBar
|
|
|
|
switch tab {
|
|
case .runtime:
|
|
runtimeSection
|
|
case .connections:
|
|
connectionSection
|
|
case .diagnostics:
|
|
diagnosticSection
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
.navigationTitle("日志中心")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
|
|
Task { await model.refresh() }
|
|
}
|
|
.disabled(model.isLoading)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"清理 7 天前的连接日志?操作不可恢复。",
|
|
isPresented: $showsPruneDialog,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("清理连接日志", role: .destructive) {
|
|
Task {
|
|
_ = await model.pruneConnections(olderThanDays: 7)
|
|
}
|
|
}
|
|
Button("取消", role: .cancel) {}
|
|
}
|
|
.refreshable {
|
|
await model.refresh()
|
|
}
|
|
.task {
|
|
await model.refresh()
|
|
}
|
|
}
|
|
|
|
private var stats: some View {
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
|
|
) {
|
|
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
|
|
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
|
|
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
|
|
}
|
|
}
|
|
|
|
private var filterBar: some View {
|
|
AICard {
|
|
VStack(spacing: 10) {
|
|
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
|
|
|
|
Picker("实例", selection: $model.selectedInstanceID) {
|
|
Text("全部实例").tag(Optional<UUID>.none)
|
|
ForEach(model.instances) { instance in
|
|
Text(instance.name).tag(Optional(instance.id))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.aiSelectSurface()
|
|
|
|
Group {
|
|
if tab == .runtime {
|
|
HStack(spacing: 10) {
|
|
Picker("来源", selection: $model.selectedSource) {
|
|
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
|
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
|
Text(source.label).tag(Optional(source))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.aiSelectSurface()
|
|
|
|
Picker("级别", selection: $model.selectedLevel) {
|
|
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
|
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
|
Text(level.label).tag(Optional(level))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.aiSelectSurface()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
} else if tab == .connections {
|
|
Picker("结果", selection: $model.selectedOutcome) {
|
|
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
|
|
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
|
|
Text(outcome.label).tag(Optional(outcome))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.aiSelectSurface()
|
|
}
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
.animation(.easeInOut(duration: 0.18), value: tab)
|
|
}
|
|
}
|
|
|
|
private var runtimeSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
|
|
if model.filteredRuntime.isEmpty {
|
|
AICard {
|
|
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
|
|
}
|
|
} else {
|
|
ForEach(model.filteredRuntime) { entry in
|
|
AIListCard(
|
|
title: entry.message,
|
|
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
|
|
status: entry.level.label,
|
|
statusTint: levelTint(entry.level),
|
|
details: {
|
|
var values = [entry.source.label]
|
|
if let duration = entry.durationMs {
|
|
values.append("耗时 \(duration) ms")
|
|
}
|
|
return values
|
|
}()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var connectionSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AISectionTitle(
|
|
"连接探测记录",
|
|
systemImage: "dot.radiowaves.left.and.right",
|
|
detail: "\(model.filteredConnections.count) 条",
|
|
actionTitle: "清理",
|
|
actionSystemImage: "trash",
|
|
actionKind: .danger
|
|
) {
|
|
showsPruneDialog = true
|
|
}
|
|
if model.filteredConnections.isEmpty {
|
|
AICard {
|
|
AIEmptyState(
|
|
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
|
|
)
|
|
}
|
|
} else {
|
|
ForEach(model.filteredConnections) { entry in
|
|
AIListCard(
|
|
title: entry.instanceName,
|
|
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
|
|
status: entry.outcome.label,
|
|
statusTint: outcomeTint(entry.outcome),
|
|
details: [
|
|
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
|
|
entry.errorCode ?? "无错误码",
|
|
]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var diagnosticSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AISectionTitle(
|
|
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
|
|
if model.filteredDiagnostics.isEmpty {
|
|
AICard {
|
|
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
|
|
}
|
|
} else {
|
|
ForEach(model.filteredDiagnostics) { diagnostic in
|
|
AIListCard(
|
|
title: diagnostic.name,
|
|
status: diagnostic.healthStatus ?? "未知",
|
|
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
|
|
details: [
|
|
diagnostic.origin,
|
|
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
|
|
]
|
|
) {
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
|
|
alignment: .leading,
|
|
spacing: 6
|
|
) {
|
|
ForEach(diagnostic.featureStatus) { feature in
|
|
AITag(feature.name, tint: featureTint(feature.state))
|
|
}
|
|
if let connection = diagnostic.connection, connection.total > 1 {
|
|
AITag(
|
|
"延迟 \(connection.averageDurationMs) ms",
|
|
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var onlineCount: Int {
|
|
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
|
|
}
|
|
|
|
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
|
switch level {
|
|
case .info: return AITheme.success
|
|
case .warning: return AITheme.warning
|
|
case .error: return AITheme.danger
|
|
}
|
|
}
|
|
|
|
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
|
|
switch outcome {
|
|
case .success: return AITheme.success
|
|
case .stale: return AITheme.warning
|
|
case .failed: return AITheme.danger
|
|
case .unsupported: return AITheme.muted
|
|
}
|
|
}
|
|
|
|
private func featureTint(_ state: FeatureStatus.State) -> Color {
|
|
switch state {
|
|
case .ready: return AITheme.success
|
|
case .requiresLogin: return AITheme.warning
|
|
case .unavailable: return AITheme.muted
|
|
}
|
|
}
|
|
}
|