Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
287 lines
9.1 KiB
Swift
287 lines
9.1 KiB
Swift
import SwiftUI
|
|
|
|
struct LogsView: View {
|
|
private enum Tab: Hashable {
|
|
case runtime
|
|
case connections
|
|
case diagnostics
|
|
}
|
|
|
|
@StateObject private var model: LogsViewModel
|
|
private let autoRefreshOnAppear: Bool
|
|
@State private var tab: Tab = .runtime
|
|
@State private var showsPruneDialog = false
|
|
|
|
init(model: LogsViewModel? = nil) {
|
|
let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false)
|
|
_model = StateObject(wrappedValue: resolvedModel)
|
|
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
|
|
}
|
|
|
|
var body: some View {
|
|
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)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.background(AITheme.canvas.ignoresSafeArea())
|
|
.navigationTitle("日志中心")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
AIToolbarButton(
|
|
"刷新",
|
|
systemImage: "arrow.clockwise",
|
|
kind: .primary
|
|
) {
|
|
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 {
|
|
if model.snapshot == nil && autoRefreshOnAppear {
|
|
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
|
|
}
|
|
}
|
|
}
|