|
| 1 | +#if DEBUG |
| 2 | +import Foundation |
| 3 | +import OSLog |
| 4 | +import SWDesignSystem |
| 5 | +import SwiftUI |
| 6 | + |
| 7 | +private final class LogStore: ObservableObject { |
| 8 | + private static let logger = Logger( |
| 9 | + subsystem: Bundle.main.bundleIdentifier!, |
| 10 | + category: String(describing: LogStore.self) |
| 11 | + ) |
| 12 | + |
| 13 | + @Published private(set) var state = State.empty |
| 14 | + var logs: [State.LogModel] { |
| 15 | + switch state { |
| 16 | + case .empty, .loading: [] |
| 17 | + case let .ready(array): array |
| 18 | + } |
| 19 | + } |
| 20 | + |
| 21 | + /// Все категории, которые есть в логах |
| 22 | + @Published private(set) var categories = [String]() |
| 23 | + /// Все уровни, которые есть в логах |
| 24 | + @Published private(set) var levels = [State.LogModel.Level]() |
| 25 | + |
| 26 | + func getLogs() async { |
| 27 | + await MainActor.run { state = .loading } |
| 28 | + do { |
| 29 | + let store = try OSLogStore(scope: .currentProcessIdentifier) |
| 30 | + let position = store.position(timeIntervalSinceLatestBoot: 1) |
| 31 | + let entries: [State.LogModel] = try store |
| 32 | + .getEntries(at: position) |
| 33 | + .compactMap { $0 as? OSLogEntryLog } |
| 34 | + .filter { $0.subsystem == Bundle.main.bundleIdentifier! } |
| 35 | + .map { |
| 36 | + .init( |
| 37 | + dateString: $0.date.formatted(date: .long, time: .standard), |
| 38 | + category: $0.category, |
| 39 | + level: .init(rawValue: $0.level.rawValue) ?? .undefined, |
| 40 | + message: $0.composedMessage |
| 41 | + ) |
| 42 | + } |
| 43 | + await MainActor.run { |
| 44 | + categories = Array(Set(entries.map(\.category))) |
| 45 | + levels = Array(Set(entries.map(\.level))) |
| 46 | + state = .ready(entries) |
| 47 | + } |
| 48 | + } catch { |
| 49 | + Self.logger.warning("\(error.localizedDescription, privacy: .public)") |
| 50 | + await MainActor.run { state = .empty } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + enum State: Equatable { |
| 55 | + case empty, loading, ready([LogModel]) |
| 56 | + |
| 57 | + var isLoading: Bool { self == .loading } |
| 58 | + |
| 59 | + struct LogModel: Identifiable, Equatable { |
| 60 | + let id = UUID() |
| 61 | + let dateString: String |
| 62 | + let category: String |
| 63 | + let level: Level |
| 64 | + let message: String |
| 65 | + |
| 66 | + enum Level: Int, CaseIterable { |
| 67 | + case undefined = 0 |
| 68 | + case debug = 1 |
| 69 | + case info = 2 |
| 70 | + case notice = 3 |
| 71 | + case error = 4 |
| 72 | + case fault = 5 |
| 73 | + |
| 74 | + var emoji: String { |
| 75 | + switch self { |
| 76 | + case .undefined: "🤨" |
| 77 | + case .debug: "🛠️" |
| 78 | + case .info: "ℹ️" |
| 79 | + case .notice: "💁♂️" |
| 80 | + case .error: "⚠️" |
| 81 | + case .fault: "⛔️" |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +struct LoggerScreen: View { |
| 90 | + @StateObject private var logStore = LogStore() |
| 91 | + @State private var categoriesToShow = [String]() |
| 92 | + @State private var levelsToShow = [LogStore.State.LogModel.Level]() |
| 93 | + @State private var showFilter = false |
| 94 | + private var isFilterOn: Bool { |
| 95 | + !categoriesToShow.isEmpty || !levelsToShow.isEmpty |
| 96 | + } |
| 97 | + |
| 98 | + private var filteredLogs: [LogStore.State.LogModel] { |
| 99 | + if isFilterOn { |
| 100 | + let filterCategories = !categoriesToShow.isEmpty |
| 101 | + let filterLevels = !levelsToShow.isEmpty |
| 102 | + return logStore.logs.filter { log in |
| 103 | + let hasCategory = filterCategories |
| 104 | + ? categoriesToShow.contains(log.category) |
| 105 | + : true |
| 106 | + let hasLevel = filterLevels |
| 107 | + ? levelsToShow.contains(log.level) |
| 108 | + : true |
| 109 | + return hasCategory && hasLevel |
| 110 | + } |
| 111 | + } else { |
| 112 | + return logStore.logs |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + var body: some View { |
| 117 | + ZStack { |
| 118 | + switch logStore.state { |
| 119 | + case .empty: |
| 120 | + Text("Логов пока нет") |
| 121 | + case .loading: |
| 122 | + Text("Загружаем логи...") |
| 123 | + case .ready: |
| 124 | + if filteredLogs.isEmpty { |
| 125 | + Text("С такими фильтрами логов нет") |
| 126 | + } else { |
| 127 | + ScrollView { |
| 128 | + LazyVStack(alignment: .leading, spacing: 16) { |
| 129 | + ForEach(Array(zip(filteredLogs.indices, filteredLogs)), id: \.0) { index, log in |
| 130 | + VStack(alignment: .leading, spacing: 8) { |
| 131 | + HStack(spacing: 8) { |
| 132 | + Text(log.level.emoji) |
| 133 | + Text(log.dateString) |
| 134 | + } |
| 135 | + Text(log.category).bold() |
| 136 | + Text(log.message) |
| 137 | + } |
| 138 | + .frame(maxWidth: .infinity, alignment: .leading) |
| 139 | + .multilineTextAlignment(.leading) |
| 140 | + .withDivider(if: index != filteredLogs.indices.last, spacing: 12) |
| 141 | + } |
| 142 | + } |
| 143 | + .padding([.top, .horizontal]) |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + .animation(.default, value: logStore.state) |
| 149 | + .loadingOverlay(if: logStore.state.isLoading) |
| 150 | + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 151 | + .navigationTitle("Логи") |
| 152 | + .background(Color.swBackground) |
| 153 | + .task { await logStore.getLogs() } |
| 154 | + .toolbar { |
| 155 | + ToolbarItem(placement: .topBarTrailing) { |
| 156 | + Button { |
| 157 | + showFilter = true |
| 158 | + } label: { |
| 159 | + Icons.Regular.filter.view |
| 160 | + .symbolVariant(isFilterOn ? .fill : .none) |
| 161 | + } |
| 162 | + .disabled(logStore.state.isLoading) |
| 163 | + } |
| 164 | + } |
| 165 | + .sheet(isPresented: $showFilter) { |
| 166 | + ContentInSheet(title: "Фильтр логов", spacing: 0) { |
| 167 | + filterView |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + private var filterView: some View { |
| 173 | + ScrollView { |
| 174 | + VStack(spacing: 32) { |
| 175 | + SectionView(header: "Категория", mode: .card()) { |
| 176 | + VStack(spacing: 0) { |
| 177 | + ForEach(Array(zip(logStore.categories.indices, logStore.categories)), id: \.0) { index, category in |
| 178 | + Button { |
| 179 | + if categoriesToShow.contains(category) { |
| 180 | + categoriesToShow = categoriesToShow.filter { $0 != category } |
| 181 | + } else { |
| 182 | + categoriesToShow.append(category) |
| 183 | + } |
| 184 | + } label: { |
| 185 | + TextWithCheckmarkRowView( |
| 186 | + text: .init(category), |
| 187 | + isChecked: categoriesToShow.contains(category) |
| 188 | + ) |
| 189 | + } |
| 190 | + .withDivider(if: index != logStore.categories.endIndex - 1) |
| 191 | + } |
| 192 | + } |
| 193 | + } |
| 194 | + SectionView(header: "Уровень", mode: .card()) { |
| 195 | + VStack(spacing: 0) { |
| 196 | + ForEach(Array(zip(logStore.levels.indices, logStore.levels)), id: \.0) { index, level in |
| 197 | + Button { |
| 198 | + if levelsToShow.contains(level) { |
| 199 | + levelsToShow = levelsToShow.filter { $0 != level } |
| 200 | + } else { |
| 201 | + levelsToShow.append(level) |
| 202 | + } |
| 203 | + } label: { |
| 204 | + TextWithCheckmarkRowView( |
| 205 | + text: .init(level.emoji), |
| 206 | + isChecked: levelsToShow.contains(level) |
| 207 | + ) |
| 208 | + } |
| 209 | + .withDivider(if: index != logStore.levels.endIndex - 1) |
| 210 | + } |
| 211 | + } |
| 212 | + } |
| 213 | + Button("Сбросить фильтры") { |
| 214 | + categoriesToShow = [] |
| 215 | + levelsToShow = [] |
| 216 | + } |
| 217 | + .buttonStyle(SWButtonStyle(mode: .filled, size: .large)) |
| 218 | + .disabled(!isFilterOn) |
| 219 | + } |
| 220 | + .padding([.top, .horizontal]) |
| 221 | + } |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +#Preview { LoggerScreen() } |
| 226 | +#endif |
0 commit comments