feat(ios): add cross-instance log center
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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
|
||||
|
||||
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 {
|
||||
AICard {
|
||||
Text(errorMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.warning)
|
||||
}
|
||||
}
|
||||
stats
|
||||
Picker("视图", selection: $tab) {
|
||||
Text("运行日志").tag(Tab.runtime)
|
||||
Text("连接日志").tag(Tab.connections)
|
||||
Text("设备诊断").tag(Tab.diagnostics)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await model.refresh() }
|
||||
} label: {
|
||||
Label("刷新", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var stats: some View {
|
||||
AICard {
|
||||
VStack(spacing: 12) {
|
||||
filterBar
|
||||
HStack(spacing: 8) {
|
||||
metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary)
|
||||
metric("在线", "\(onlineCount)", AITheme.success)
|
||||
metric("离线", "\(model.offlineCount)", AITheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 10) {
|
||||
TextField("搜索日志、错误码或实例", text: $model.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.buttonHeight)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
if tab == .runtime {
|
||||
Picker("来源", selection: $model.selectedSource) {
|
||||
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
||||
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
||||
Text(source.label).tag(Optional(source))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Picker("级别", selection: $model.selectedLevel) {
|
||||
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
||||
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
||||
Text(level.label).tag(Optional(level))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
empty("暂无运行日志。")
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(entry.level.label, tint: levelTint(entry.level))
|
||||
}
|
||||
Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
HStack(spacing: 6) {
|
||||
AITag(entry.source.label, tint: AITheme.primary)
|
||||
if let duration = entry.durationMs {
|
||||
AITag("\(duration) ms", tint: AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
|
||||
Button {
|
||||
Task {
|
||||
_ = await model.pruneConnections(olderThanDays: 7)
|
||||
}
|
||||
} label: {
|
||||
Label("清理", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
||||
}
|
||||
if model.filteredConnections.isEmpty {
|
||||
empty("暂无连接日志。")
|
||||
} else {
|
||||
ForEach(model.filteredConnections) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.instanceName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(entry.outcome.label, tint: outcomeTint(entry.outcome))
|
||||
}
|
||||
Text("\(entry.observedAt.formatted()) · \(entry.state)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台")
|
||||
if model.filteredDiagnostics.isEmpty {
|
||||
empty("暂无可诊断实例。")
|
||||
} else {
|
||||
ForEach(model.filteredDiagnostics) { diagnostic in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text(diagnostic.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(
|
||||
diagnostic.healthStatus ?? "未知",
|
||||
tint: diagnostic.state == .online ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
Text(diagnostic.origin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
if let version = diagnostic.version {
|
||||
Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
AITag(
|
||||
"能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
if let connection = diagnostic.connection {
|
||||
AITag(
|
||||
"可用 \(connection.availabilityPercent)%",
|
||||
tint: connection.failed == 0 ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var onlineCount: Int {
|
||||
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
|
||||
}
|
||||
|
||||
private func metric(_ title: String, _ value: String, _ tint: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func sectionTitle(_ title: String, detail: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user