git.iko.soy/parseable-macos
on master help sign in
Add severity-based row background tinting to log table (#28)
eb5d360iko
ParseableViewer/Views/LogTableView.swift+106−6
@@ -48,6 +48,100 @@ func levelColor(for value: String) -> Color {
48 }
48 }
49}
49}
50
50
51+// MARK: - Severity Row Tinting
52+
53+/// Semantic severity levels for row background tinting.
54+enum SeverityLevel {
55+ case fatal
56+ case error
57+ case warning
58+ case info
59+ case debug
60+ case trace
61+ case unknown
62+}
63+
64+/// Column names (lowercased) that commonly hold a severity / log-level value.
65+private let severityColumnNames: Set<String> = [
66+ "level", "severity", "log_level", "loglevel", "log.level",
67+ "p_level", "severity_text", "levelname", "log_severity",
68+ "priority", "syslog_severity", "msg_severity", "verbosity",
69+ "otel.severity", "severity_number",
70+]
71+
72+/// Maps a raw severity string to a ``SeverityLevel``.
73+func parseSeverity(from value: String) -> SeverityLevel {
74+ let lower = value.trimmingCharacters(in: .whitespaces).lowercased()
75+
76+ // Try well-known textual levels first.
77+ switch lower {
78+ // Fatal / Critical
79+ case "fatal", "critical", "panic", "emerg", "emergency", "alert", "crit":
80+ return .fatal
81+ // Error
82+ case "error", "err", "failure", "fail", "severe":
83+ return .error
84+ // Warning
85+ case "warn", "warning", "caution":
86+ return .warning
87+ // Info / Notice
88+ case "info", "information", "informational", "notice":
89+ return .info
90+ // Debug
91+ case "debug", "dbg", "verbose":
92+ return .debug
93+ // Trace
94+ case "trace", "finest", "finer", "fine", "all":
95+ return .trace
96+ default:
97+ break
98+ }
99+
100+ // Try numeric syslog severity (RFC 5424): 0=Emergency … 7=Debug.
101+ if let num = Int(lower) {
102+ switch num {
103+ case 0...1: return .fatal // Emergency, Alert
104+ case 2: return .fatal // Critical
105+ case 3: return .error // Error
106+ case 4: return .warning // Warning
107+ case 5: return .info // Notice
108+ case 6: return .info // Informational
109+ case 7: return .debug // Debug
110+ default: break
111+ }
112+ }
113+
114+ return .unknown
115+}
116+
117+/// Extracts the severity level from a log record by checking common column names.
118+func extractSeverity(from record: LogRecord) -> SeverityLevel {
119+ for (key, value) in record {
120+ if severityColumnNames.contains(key.lowercased()) {
121+ let str: String
122+ switch value {
123+ case .string(let s): str = s
124+ case .int(let i): str = String(i)
125+ default: continue
126+ }
127+ let level = parseSeverity(from: str)
128+ if level != .unknown { return level }
129+ }
130+ }
131+ return .unknown
132+}
133+
134+/// Returns a subtle background tint color for a severity level.
135+/// Info, debug, trace, and unknown levels return `nil` (no tint).
136+func severityRowTint(for level: SeverityLevel) -> Color? {
137+ switch level {
138+ case .fatal: return Color.red.opacity(0.12)
139+ case .error: return Color.red.opacity(0.07)
140+ case .warning: return Color.orange.opacity(0.07)
141+ case .info, .debug, .trace, .unknown: return nil
142+ }
143+}
144+
51// MARK: - LogTableView
145// MARK: - LogTableView
52
146
53struct LogTableView: View {
147struct LogTableView: View {
@@ -470,16 +564,22 @@ struct LogRowView: View {
470 }
564 }
471 }
565 }
472 }
566 }
473− .background(
567+ .background {
474− isSelected
568+ if isSelected {
475− ? Color.accentColor.opacity(0.2)
569+ Color.accentColor.opacity(0.2)
570+ } else if let tint = severityRowTint(for: extractSeverity(from: record)) {
571+ tint
572+ } else if isAlternate {
476− : (isAlternate ? Color.primary.opacity(0.02) : Color.clear)
573+ Color.primary.opacity(0.02)
477− )
574+ } else {
575+ Color.clear
576+ }
577+ }
478 }
578 }
479
579
480 private func colorForValue(column: String, value: JSONValue?) -> Color {
580 private func colorForValue(column: String, value: JSONValue?) -> Color {
481 guard let value else { return .secondary }
581 guard let value else { return .secondary }
482− if column == "level" || column == "severity" || column == "log_level" {
582+ if severityColumnNames.contains(column.lowercased()) {
483 return levelColor(for: value.displayString)
583 return levelColor(for: value.displayString)
484 }
584 }
485 return .primary
585 return .primary
ParseableViewerTests/LogTableHelperTests.swift+141
@@ -82,4 +82,145 @@ final class LogTableHelperTests: XCTestCase {
82 XCTAssertEqual(levelColor(for: "unknown"), .primary)
82 XCTAssertEqual(levelColor(for: "unknown"), .primary)
83 XCTAssertEqual(levelColor(for: ""), .primary)
83 XCTAssertEqual(levelColor(for: ""), .primary)
84 }
84 }
85+
86+ // MARK: - parseSeverity
87+
88+ func testParseSeverityFatal() {
89+ XCTAssertEqual(parseSeverity(from: "fatal"), .fatal)
90+ XCTAssertEqual(parseSeverity(from: "CRITICAL"), .fatal)
91+ XCTAssertEqual(parseSeverity(from: "panic"), .fatal)
92+ XCTAssertEqual(parseSeverity(from: "emerg"), .fatal)
93+ XCTAssertEqual(parseSeverity(from: "EMERGENCY"), .fatal)
94+ XCTAssertEqual(parseSeverity(from: "alert"), .fatal)
95+ XCTAssertEqual(parseSeverity(from: "crit"), .fatal)
96+ }
97+
98+ func testParseSeverityError() {
99+ XCTAssertEqual(parseSeverity(from: "error"), .error)
100+ XCTAssertEqual(parseSeverity(from: "ERROR"), .error)
101+ XCTAssertEqual(parseSeverity(from: "err"), .error)
102+ XCTAssertEqual(parseSeverity(from: "failure"), .error)
103+ XCTAssertEqual(parseSeverity(from: "FAIL"), .error)
104+ XCTAssertEqual(parseSeverity(from: "severe"), .error)
105+ }
106+
107+ func testParseSeverityWarning() {
108+ XCTAssertEqual(parseSeverity(from: "warn"), .warning)
109+ XCTAssertEqual(parseSeverity(from: "WARNING"), .warning)
110+ XCTAssertEqual(parseSeverity(from: "caution"), .warning)
111+ }
112+
113+ func testParseSeverityInfo() {
114+ XCTAssertEqual(parseSeverity(from: "info"), .info)
115+ XCTAssertEqual(parseSeverity(from: "INFO"), .info)
116+ XCTAssertEqual(parseSeverity(from: "information"), .info)
117+ XCTAssertEqual(parseSeverity(from: "informational"), .info)
118+ XCTAssertEqual(parseSeverity(from: "notice"), .info)
119+ }
120+
121+ func testParseSeverityDebug() {
122+ XCTAssertEqual(parseSeverity(from: "debug"), .debug)
123+ XCTAssertEqual(parseSeverity(from: "DEBUG"), .debug)
124+ XCTAssertEqual(parseSeverity(from: "dbg"), .debug)
125+ XCTAssertEqual(parseSeverity(from: "verbose"), .debug)
126+ }
127+
128+ func testParseSeverityTrace() {
129+ XCTAssertEqual(parseSeverity(from: "trace"), .trace)
130+ XCTAssertEqual(parseSeverity(from: "TRACE"), .trace)
131+ XCTAssertEqual(parseSeverity(from: "finest"), .trace)
132+ XCTAssertEqual(parseSeverity(from: "finer"), .trace)
133+ XCTAssertEqual(parseSeverity(from: "fine"), .trace)
134+ XCTAssertEqual(parseSeverity(from: "all"), .trace)
135+ }
136+
137+ func testParseSeverityNumericSyslog() {
138+ // RFC 5424 severity codes
139+ XCTAssertEqual(parseSeverity(from: "0"), .fatal) // Emergency
140+ XCTAssertEqual(parseSeverity(from: "1"), .fatal) // Alert
141+ XCTAssertEqual(parseSeverity(from: "2"), .fatal) // Critical
142+ XCTAssertEqual(parseSeverity(from: "3"), .error) // Error
143+ XCTAssertEqual(parseSeverity(from: "4"), .warning) // Warning
144+ XCTAssertEqual(parseSeverity(from: "5"), .info) // Notice
145+ XCTAssertEqual(parseSeverity(from: "6"), .info) // Informational
146+ XCTAssertEqual(parseSeverity(from: "7"), .debug) // Debug
147+ }
148+
149+ func testParseSeverityUnknown() {
150+ XCTAssertEqual(parseSeverity(from: ""), .unknown)
151+ XCTAssertEqual(parseSeverity(from: "something"), .unknown)
152+ XCTAssertEqual(parseSeverity(from: "99"), .unknown)
153+ }
154+
155+ func testParseSeverityWhitespace() {
156+ XCTAssertEqual(parseSeverity(from: " error "), .error)
157+ XCTAssertEqual(parseSeverity(from: " WARN "), .warning)
158+ }
159+
160+ // MARK: - extractSeverity
161+
162+ func testExtractSeverityFromLevelColumn() {
163+ let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
164+ XCTAssertEqual(extractSeverity(from: record), .error)
165+ }
166+
167+ func testExtractSeverityFromSeverityColumn() {
168+ let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
169+ XCTAssertEqual(extractSeverity(from: record), .warning)
170+ }
171+
172+ func testExtractSeverityFromSeverityTextColumn() {
173+ let record: LogRecord = ["severity_text": .string("FATAL")]
174+ XCTAssertEqual(extractSeverity(from: record), .fatal)
175+ }
176+
177+ func testExtractSeverityFromLogLevelColumn() {
178+ let record: LogRecord = ["log_level": .string("info")]
179+ XCTAssertEqual(extractSeverity(from: record), .info)
180+ }
181+
182+ func testExtractSeverityFromNumericValue() {
183+ let record: LogRecord = ["priority": .int(3)]
184+ XCTAssertEqual(extractSeverity(from: record), .error)
185+ }
186+
187+ func testExtractSeverityNoSeverityColumn() {
188+ let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
189+ XCTAssertEqual(extractSeverity(from: record), .unknown)
190+ }
191+
192+ func testExtractSeverityUnrecognizedValue() {
193+ let record: LogRecord = ["level": .string("custom_level")]
194+ XCTAssertEqual(extractSeverity(from: record), .unknown)
195+ }
196+
197+ // MARK: - severityRowTint
198+
199+ func testSeverityRowTintFatalReturnsColor() {
200+ XCTAssertNotNil(severityRowTint(for: .fatal))
201+ }
202+
203+ func testSeverityRowTintErrorReturnsColor() {
204+ XCTAssertNotNil(severityRowTint(for: .error))
205+ }
206+
207+ func testSeverityRowTintWarningReturnsColor() {
208+ XCTAssertNotNil(severityRowTint(for: .warning))
209+ }
210+
211+ func testSeverityRowTintInfoReturnsNil() {
212+ XCTAssertNil(severityRowTint(for: .info))
213+ }
214+
215+ func testSeverityRowTintDebugReturnsNil() {
216+ XCTAssertNil(severityRowTint(for: .debug))
217+ }
218+
219+ func testSeverityRowTintTraceReturnsNil() {
220+ XCTAssertNil(severityRowTint(for: .trace))
221+ }
222+
223+ func testSeverityRowTintUnknownReturnsNil() {
224+ XCTAssertNil(severityRowTint(for: .unknown))
225+ }
85}
226}