Improve debug logging system
- Redesign logging configuration UI with cleaner list-based navigation - Replace segmented controls with detailed selection screens for better UX - Add in-app debug log viewer with filtering and search capabilities - Implement opt-in logging toggle to reduce device performance impact - Add log storage system with 1000 entry limit - Enable log export via share sheet - Show warning banner when logging is disabled
This commit is contained in:
parent
4b93c605f1
commit
7338db5fab
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
//
|
||||||
|
// DebugLogViewer.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DebugLogViewer: View {
|
||||||
|
@State private var entries: [LogEntry] = []
|
||||||
|
@State private var selectedLevel: LogLevel?
|
||||||
|
@State private var selectedCategory: LogCategory?
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var exportText = ""
|
||||||
|
@State private var autoScroll = true
|
||||||
|
@State private var showFilters = false
|
||||||
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
|
|
||||||
|
private let logger = Logger.ui
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Logging Disabled Warning
|
||||||
|
if !logConfig.isLoggingEnabled {
|
||||||
|
loggingDisabledBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Bar
|
||||||
|
if showFilters {
|
||||||
|
filterBar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log List
|
||||||
|
if filteredEntries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List {
|
||||||
|
ForEach(filteredEntries) { entry in
|
||||||
|
LogEntryRow(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.onChange(of: entries.count) { oldValue, newValue in
|
||||||
|
if autoScroll, let lastEntry = filteredEntries.last {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Debug Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showFilters.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
showFilters ? "Hide Filters" : "Show Filters",
|
||||||
|
systemImage: "line.3.horizontal.decrease.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
autoScroll.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
||||||
|
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await exportLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Export Logs", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await clearLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Clear All Logs", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search logs")
|
||||||
|
.task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
ActivityView(activityItems: [exportText])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var filterBar: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Filters")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Level Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Levels") {
|
||||||
|
selectedLevel = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
selectedLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
if selectedLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Categories") {
|
||||||
|
selectedCategory = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
|
Button {
|
||||||
|
selectedCategory = category
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(category.rawValue)
|
||||||
|
if selectedCategory == category {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
Text(selectedCategory?.rawValue ?? "Category")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Filters
|
||||||
|
if selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Button {
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
Text("Clear")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loggingDisabledBanner: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Logging Disabled")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Enable logging in settings to capture new logs")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
logConfig.isLoggingEnabled = true
|
||||||
|
} label: {
|
||||||
|
Text("Enable")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.orange)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("No Logs Found")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Text("Try adjusting your filters or search criteria")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
Text("Clear Filters")
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Logs will appear here as they are generated")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredEntries: [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = selectedLevel {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = selectedCategory {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLogs() async {
|
||||||
|
entries = await LogStore.shared.getEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearLogs() async {
|
||||||
|
await LogStore.shared.clear()
|
||||||
|
await refreshLogs()
|
||||||
|
logger.info("Cleared all debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportLogs() async {
|
||||||
|
exportText = await LogStore.shared.exportAsText()
|
||||||
|
showShareSheet = true
|
||||||
|
logger.info("Exported debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Entry Row
|
||||||
|
|
||||||
|
struct LogEntryRow: View {
|
||||||
|
let entry: LogEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
// Level Badge
|
||||||
|
Text(levelName(for: entry.level))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(levelColor(for: entry.level).opacity(0.2))
|
||||||
|
.foregroundColor(levelColor(for: entry.level))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Category
|
||||||
|
Text(entry.category.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
Text(entry.formattedTimestamp)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message
|
||||||
|
Text(entry.message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
// Source Location
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(entry.fileName):\(entry.line)")
|
||||||
|
.font(.caption)
|
||||||
|
Text("•")
|
||||||
|
.font(.caption)
|
||||||
|
Text(entry.function)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARN"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelColor(for level: LogLevel) -> Color {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return .blue
|
||||||
|
case 1: return .green
|
||||||
|
case 2: return .cyan
|
||||||
|
case 3: return .orange
|
||||||
|
case 4: return .red
|
||||||
|
case 5: return .purple
|
||||||
|
default: return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity View (for Share Sheet)
|
||||||
|
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,6 @@
|
|||||||
// Created by Ilyas Hallak on 16.08.25.
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -15,82 +13,70 @@ struct LoggingConfigurationView: View {
|
|||||||
private let logger = Logger.ui
|
private let logger = Logger.ui
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
List {
|
||||||
Form {
|
Section {
|
||||||
Section(header: Text("Global Settings")) {
|
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.tint(.green)
|
||||||
Text("Global Minimum Level")
|
} header: {
|
||||||
.font(.headline)
|
Text("Logging Status")
|
||||||
|
} footer: {
|
||||||
|
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
|
||||||
|
}
|
||||||
|
|
||||||
Picker("Global Level", selection: $logConfig.globalMinLevel) {
|
if logConfig.isLoggingEnabled {
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
GlobalLogLevelView(logConfig: logConfig)
|
||||||
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(level.emoji)
|
Label("Global Log Level", systemImage: "slider.horizontal.3")
|
||||||
Text(level.rawValue == 0 ? "Debug" :
|
Spacer()
|
||||||
level.rawValue == 1 ? "Info" :
|
Text(levelName(for: logConfig.globalMinLevel))
|
||||||
level.rawValue == 2 ? "Notice" :
|
|
||||||
level.rawValue == 3 ? "Warning" :
|
|
||||||
level.rawValue == 4 ? "Error" : "Critical")
|
|
||||||
}
|
|
||||||
.tag(level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
|
||||||
|
|
||||||
Text("Logs below this level will be filtered out globally")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
||||||
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
||||||
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
||||||
|
} header: {
|
||||||
|
Text("Global Settings")
|
||||||
|
} footer: {
|
||||||
|
Text("Logs below the global level will be filtered out globally")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Category-specific Levels")) {
|
if logConfig.isLoggingEnabled {
|
||||||
|
Section {
|
||||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
NavigationLink {
|
||||||
|
CategoryLogLevelView(category: category, logConfig: logConfig)
|
||||||
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(category.rawValue)
|
Text(category.rawValue)
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(levelName(for: logConfig.getLevel(for: category)))
|
Text(levelName(for: logConfig.getLevel(for: category)))
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker("Level for \(category.rawValue)", selection: Binding(
|
|
||||||
get: { logConfig.getLevel(for: category) },
|
|
||||||
set: { logConfig.setLevel($0, for: category) }
|
|
||||||
)) {
|
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
|
||||||
HStack {
|
|
||||||
Text(level.emoji)
|
|
||||||
Text(levelName(for: level))
|
|
||||||
}
|
|
||||||
.tag(level)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
} header: {
|
||||||
}
|
Text("Category-specific Levels")
|
||||||
.padding(.vertical, 4)
|
} footer: {
|
||||||
|
Text("Configure log levels for each category individually")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Reset")) {
|
Section {
|
||||||
Button("Reset to Defaults") {
|
Button(role: .destructive) {
|
||||||
resetToDefaults()
|
resetToDefaults()
|
||||||
|
} label: {
|
||||||
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||||
}
|
}
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Logging Configuration")
|
.navigationTitle("Logging Configuration")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("Opened logging configuration view")
|
logger.debug("Opened logging configuration view")
|
||||||
}
|
}
|
||||||
@ -111,12 +97,10 @@ struct LoggingConfigurationView: View {
|
|||||||
private func resetToDefaults() {
|
private func resetToDefaults() {
|
||||||
logger.info("Resetting logging configuration to defaults")
|
logger.info("Resetting logging configuration to defaults")
|
||||||
|
|
||||||
// Reset all category levels (this will use globalMinLevel as fallback)
|
|
||||||
for category in LogCategory.allCases {
|
for category in LogCategory.allCases {
|
||||||
logConfig.setLevel(.debug, for: category)
|
logConfig.setLevel(.debug, for: category)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset global settings
|
|
||||||
logConfig.globalMinLevel = .debug
|
logConfig.globalMinLevel = .debug
|
||||||
logConfig.showPerformanceLogs = true
|
logConfig.showPerformanceLogs = true
|
||||||
logConfig.showTimestamps = true
|
logConfig.showTimestamps = true
|
||||||
@ -126,6 +110,123 @@ struct LoggingConfigurationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Global Log Level View
|
||||||
|
|
||||||
|
struct GlobalLogLevelView: View {
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.globalMinLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.globalMinLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Global Log Level")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Log Level View
|
||||||
|
|
||||||
|
struct CategoryLogLevelView: View {
|
||||||
|
let category: LogCategory
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.setLevel(level, for: category)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.getLevel(for: category) == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(category.rawValue) Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
LoggingConfigurationView()
|
LoggingConfigurationView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -46,8 +46,17 @@ struct SettingsContainerView: View {
|
|||||||
private var debugSettingsSection: some View {
|
private var debugSettingsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
SettingsRowNavigationLink(
|
SettingsRowNavigationLink(
|
||||||
icon: "doc.text.magnifyingglass",
|
icon: "list.bullet.rectangle",
|
||||||
iconColor: .blue,
|
iconColor: .blue,
|
||||||
|
title: "Debug Logs",
|
||||||
|
subtitle: "View all debug messages"
|
||||||
|
) {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "slider.horizontal.3",
|
||||||
|
iconColor: .purple,
|
||||||
title: "Logging Configuration",
|
title: "Logging Configuration",
|
||||||
subtitle: "Configure log levels and categories"
|
subtitle: "Configure log levels and categories"
|
||||||
) {
|
) {
|
||||||
|
|||||||
145
readeck/Utils/LogStore.swift
Normal file
145
readeck/Utils/LogStore.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// LogStore.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Log Entry
|
||||||
|
|
||||||
|
struct LogEntry: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let timestamp: Date
|
||||||
|
let level: LogLevel
|
||||||
|
let category: LogCategory
|
||||||
|
let message: String
|
||||||
|
let file: String
|
||||||
|
let function: String
|
||||||
|
let line: Int
|
||||||
|
|
||||||
|
var fileName: String {
|
||||||
|
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
DateFormatter.logTimestamp.string(from: timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
level: LogLevel,
|
||||||
|
category: LogCategory,
|
||||||
|
message: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.level = level
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.file = file
|
||||||
|
self.function = function
|
||||||
|
self.line = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Store
|
||||||
|
|
||||||
|
actor LogStore {
|
||||||
|
static let shared = LogStore()
|
||||||
|
|
||||||
|
private var entries: [LogEntry] = []
|
||||||
|
private let maxEntries: Int
|
||||||
|
|
||||||
|
private init(maxEntries: Int = 1000) {
|
||||||
|
self.maxEntries = maxEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ entry: LogEntry) {
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
// Keep only the most recent entries
|
||||||
|
if entries.count > maxEntries {
|
||||||
|
entries.removeFirst(entries.count - maxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries() -> [LogEntry] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries(
|
||||||
|
level: LogLevel? = nil,
|
||||||
|
category: LogCategory? = nil,
|
||||||
|
searchText: String? = nil
|
||||||
|
) -> [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = level {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = category {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let searchText = searchText, !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
entries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsText() -> String {
|
||||||
|
var text = "Readeck Debug Logs\n"
|
||||||
|
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
||||||
|
text += "Total Entries: \(entries.count)\n"
|
||||||
|
text += String(repeating: "=", count: 80) + "\n\n"
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
text += "[\(entry.formattedTimestamp)] "
|
||||||
|
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
||||||
|
text += "[\(entry.category.rawValue)] "
|
||||||
|
text += "\(entry.fileName):\(entry.line) "
|
||||||
|
text += "\(entry.function)\n"
|
||||||
|
text += " \(entry.message)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARNING"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFormatter Extension
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let exportTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import os
|
|||||||
|
|
||||||
// MARK: - Log Configuration
|
// MARK: - Log Configuration
|
||||||
|
|
||||||
enum LogLevel: Int, CaseIterable {
|
enum LogLevel: Int, CaseIterable, Codable {
|
||||||
case debug = 0
|
case debug = 0
|
||||||
case info = 1
|
case info = 1
|
||||||
case notice = 2
|
case notice = 2
|
||||||
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogCategory: String, CaseIterable {
|
enum LogCategory: String, CaseIterable, Codable {
|
||||||
case network = "Network"
|
case network = "Network"
|
||||||
case ui = "UI"
|
case ui = "UI"
|
||||||
case data = "Data"
|
case data = "Data"
|
||||||
@ -49,6 +49,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
@Published var showPerformanceLogs = true
|
@Published var showPerformanceLogs = true
|
||||||
@Published var showTimestamps = true
|
@Published var showTimestamps = true
|
||||||
@Published var includeSourceLocation = true
|
@Published var includeSourceLocation = true
|
||||||
|
@Published var isLoggingEnabled = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
loadConfiguration()
|
loadConfiguration()
|
||||||
@ -64,6 +65,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
||||||
|
guard isLoggingEnabled else { return false }
|
||||||
let categoryLevel = getLevel(for: category)
|
let categoryLevel = getLevel(for: category)
|
||||||
return level.rawValue >= categoryLevel.rawValue
|
return level.rawValue >= categoryLevel.rawValue
|
||||||
}
|
}
|
||||||
@ -84,6 +86,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
||||||
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
||||||
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
||||||
|
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveConfiguration() {
|
private func saveConfiguration() {
|
||||||
@ -96,6 +99,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
||||||
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
||||||
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
||||||
|
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,36 +119,61 @@ struct Logger {
|
|||||||
guard config.shouldLog(.debug, for: category) else { return }
|
guard config.shouldLog(.debug, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
||||||
logger.debug("\(formattedMessage)")
|
logger.debug("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .debug, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.info, for: category) else { return }
|
guard config.shouldLog(.info, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
||||||
logger.info("\(formattedMessage)")
|
logger.info("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .info, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.notice, for: category) else { return }
|
guard config.shouldLog(.notice, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
||||||
logger.notice("\(formattedMessage)")
|
logger.notice("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .notice, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.warning, for: category) else { return }
|
guard config.shouldLog(.warning, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
||||||
logger.warning("\(formattedMessage)")
|
logger.warning("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .warning, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.error, for: category) else { return }
|
guard config.shouldLog(.error, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
||||||
logger.error("\(formattedMessage)")
|
logger.error("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .error, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.critical, for: category) else { return }
|
guard config.shouldLog(.critical, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
||||||
logger.critical("\(formattedMessage)")
|
logger.critical("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .critical, file: file, function: function, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Store Log
|
||||||
|
|
||||||
|
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
|
||||||
|
#if DEBUG
|
||||||
|
guard config.isLoggingEnabled else { return }
|
||||||
|
let entry = LogEntry(
|
||||||
|
level: level,
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
file: file,
|
||||||
|
function: function,
|
||||||
|
line: line
|
||||||
|
)
|
||||||
|
Task {
|
||||||
|
await LogStore.shared.addEntry(entry)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Methods
|
// MARK: - Convenience Methods
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user