mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:54:50 +00:00
249 lines
7.6 KiB
Swift
249 lines
7.6 KiB
Swift
import SwiftUI
|
|
|
|
/// 独立诊断窗口:可筛选的日志表格与选中行详情。
|
|
/// 日志只在查看器中呈现;诊断导出继续通过 DiagnosticsWorkflow 准备脱敏报告。
|
|
struct DiagnosticsView: View {
|
|
@ObservedObject var diagnostics: DiagnosticsWorkflow
|
|
@ObservedObject var windowState: DiagnosticsWindowState
|
|
let clipboard: any TextClipboard
|
|
let exporter: any DiagnosticReportExporter
|
|
|
|
@State private var selection: Set<DiagnosticLogEntry.ID> = []
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
logCard
|
|
Text(
|
|
"诊断导出只包含状态、存在性、权限和脱敏元数据,不包含密码、插件参数、订阅 URL 或运行时 JSON。"
|
|
)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.leading, 28)
|
|
.padding(.trailing, 32)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 36)
|
|
.frame(minWidth: 800, minHeight: 520)
|
|
.background(DiagnosticsWindowAnchor(state: windowState).frame(width: 0, height: 0))
|
|
.task(id: windowState.isOpen) {
|
|
if windowState.isOpen { await diagnostics.readWhileActive() }
|
|
}
|
|
.presentingErrors(windowState.errors)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("导出诊断…", systemImage: "square.and.arrow.up") {
|
|
exportDiagnostics()
|
|
}
|
|
}
|
|
}
|
|
.alert(
|
|
"诊断已导出",
|
|
isPresented: Binding(
|
|
get: { windowState.exportedDiagnosticsPath != nil },
|
|
set: { if !$0 { windowState.exportedDiagnosticsPath = nil } })
|
|
) {
|
|
Button("好", role: .cancel) {}
|
|
} message: {
|
|
Text(windowState.exportedDiagnosticsPath ?? "")
|
|
}
|
|
}
|
|
|
|
/// 诊断导出:沿用 DiagnosticReportExportAction 既有入口;文件写
|
|
/// 入成功后才登记导出完成事件。
|
|
private func exportDiagnostics() {
|
|
switch DiagnosticReportExportAction(
|
|
diagnostics: diagnostics, exporter: exporter
|
|
).perform()
|
|
{
|
|
case .preparationFailed(let failure):
|
|
windowState.errors.present(failure)
|
|
case .cancelled:
|
|
break
|
|
case .saved(let url):
|
|
diagnostics.noteExportCompleted()
|
|
windowState.exportedDiagnosticsPath = url.path
|
|
case .exportFailed(let failure):
|
|
windowState.errors.present(failure)
|
|
}
|
|
}
|
|
|
|
// MARK: - 日志卡
|
|
|
|
private var logCard: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
Picker("日志级别", selection: $windowState.filter.level) {
|
|
Text("全部").tag(DiagnosticLogFilter.Level.all)
|
|
Text("警告+错误").tag(DiagnosticLogFilter.Level.warnings)
|
|
Text("仅错误").tag(DiagnosticLogFilter.Level.errors)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.fixedSize()
|
|
Picker("日志来源", selection: $windowState.filter.source) {
|
|
Text("全部").tag(DiagnosticLogFilter.Source.all)
|
|
Text("GUI").tag(DiagnosticLogFilter.Source.gui)
|
|
Text("wrapper").tag(DiagnosticLogFilter.Source.wrapper)
|
|
Text("sslocal").tag(DiagnosticLogFilter.Source.sslocal)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.fixedSize()
|
|
Spacer(minLength: 12)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
Divider()
|
|
DiagnosticsLogSplitView {
|
|
logTable
|
|
} details: {
|
|
logDetails
|
|
}
|
|
.frame(minHeight: 300, maxHeight: .infinity)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
.background.secondary,
|
|
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.quaternary)
|
|
)
|
|
}
|
|
|
|
private var visibleRows: [DiagnosticLogEntry] {
|
|
diagnostics.logView.entries.filter { windowState.filter.admits($0.line) }
|
|
}
|
|
|
|
private var selectedRows: [DiagnosticLogEntry] {
|
|
visibleRows.filter { selection.contains($0.id) }
|
|
}
|
|
|
|
/// 空态提示:无过滤时提示如何产生日志;过滤后只提示过滤条件。
|
|
private var emptyMessage: String {
|
|
if windowState.filter == DiagnosticLogFilter() {
|
|
return String(
|
|
localized: "暂无日志;开关代理或激活服务器后,GUI 事件、wrapper 与 sslocal 的日志行会实时出现在这里。")
|
|
}
|
|
return String(localized: "当前过滤条件下没有日志行。")
|
|
}
|
|
|
|
private var logTable: some View {
|
|
let rows = visibleRows
|
|
let selected = selectedRows
|
|
return Table(rows, selection: $selection) {
|
|
TableColumn("时间") { row in
|
|
Text(row.tableTimestampText)
|
|
.help(row.timestampText)
|
|
}
|
|
.width(min: 90, ideal: 105)
|
|
TableColumn("日志级别") { row in
|
|
Text(row.line.level.rawValue)
|
|
}
|
|
.width(min: 60, ideal: 70)
|
|
TableColumn("分类") { row in
|
|
Text(row.sourceText)
|
|
}
|
|
.width(min: 60, ideal: 70)
|
|
TableColumn("消息") { row in
|
|
Text(row.line.text)
|
|
}
|
|
.width(min: 160, ideal: 520)
|
|
}
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.onCopyCommand(
|
|
perform: selected.isEmpty
|
|
? nil
|
|
: {
|
|
[
|
|
NSItemProvider(
|
|
object: selected.map(\.line.rendered).joined(separator: "\n") as NSString)
|
|
]
|
|
}
|
|
)
|
|
.contextMenu(forSelectionType: DiagnosticLogEntry.ID.self) { ids in
|
|
Button("复制") {
|
|
copyToClipboard(
|
|
rows.filter { ids.contains($0.id) }.map(\.line.rendered).joined(separator: "\n"))
|
|
}
|
|
.disabled(ids.isEmpty)
|
|
}
|
|
.onChange(of: rows.map(\.id)) { _, ids in
|
|
selection.formIntersection(ids)
|
|
}
|
|
.overlay {
|
|
if rows.isEmpty {
|
|
Text(emptyMessage)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.padding(16)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logDetails: some View {
|
|
let selected = selectedRows
|
|
return ScrollView {
|
|
if selected.count == 1, let row = selected.first {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
LabeledContent("时间", value: row.timestampText)
|
|
LabeledContent("日志级别", value: row.line.level.rawValue)
|
|
LabeledContent("分类", value: row.sourceText)
|
|
Divider()
|
|
Text("消息")
|
|
.font(.headline)
|
|
Text(row.line.text)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.textSelection(.enabled)
|
|
.padding(16)
|
|
} else {
|
|
Group {
|
|
if selected.isEmpty {
|
|
Text("选择日志查看详情")
|
|
} else {
|
|
Text("已选择 \(selected.count) 条日志")
|
|
}
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
do {
|
|
try clipboard.write(text)
|
|
} catch {
|
|
windowState.errors.present(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DiagnosticLogEntry {
|
|
private static let tableTimestampFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
|
return formatter
|
|
}()
|
|
|
|
var tableTimestampText: String {
|
|
line.timestamp.map { Self.tableTimestampFormatter.string(from: $0) } ?? "—"
|
|
}
|
|
|
|
var timestampText: String {
|
|
line.timestamp.map { LogLine.timestampFormatter.string(from: $0) } ?? "—"
|
|
}
|
|
|
|
var sourceText: String { line.source == .gui ? "GUI" : line.source.rawValue }
|
|
}
|