llv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/llv/parser.rb ADDED
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llv
4
+ # Turns a raw log line into a structured Event. Stateful per-stream so that
5
+ # untagged HTTP lines (when the host app hasn't configured
6
+ # `config.log_tags = [:request_id]`) can still be grouped via Started/Completed
7
+ # bracketing.
8
+ class Parser
9
+ Event = Struct.new(
10
+ :group_id, # e.g. "http:<uuid>", "job:<uuid>", "http_synth:1", "untagged"
11
+ :group_kind, # :http | :job | :untagged
12
+ :group_title, # presentational, may be nil until a "Started"/"Performing"
13
+ :type, # :request_started | :processing | :parameters | :sql | :sql_source |
14
+ # :render | :cache | :request_completed | :job_enqueued |
15
+ # :job_performing | :job_performed | :job_retry_stopped | :other
16
+ :payload, # Hash of captured fields per type
17
+ :raw, # original line content (after stripping leading tags), SGR intact
18
+ :plain, # ANSI-stripped version of raw, for matching/measurement
19
+ keyword_init: true
20
+ )
21
+
22
+ # Rails::TaggedLogging emits "[tag] " (single space) for each tag, so we
23
+ # only consume one space after each bracket — anything more is the
24
+ # message's own indentation and is meaningful for classification.
25
+ TAG_RE = /\A(\[[^\]]*\] )+/
26
+ UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
27
+
28
+ CLASSIFIERS = [
29
+ [
30
+ :request_started,
31
+ # The optional bracketed segment matches `[WebSocket]` for ActionCable
32
+ # upgrade lines and similar transport markers Rails sometimes emits.
33
+ /\AStarted\s+(?<method>[A-Z]+)\s+"(?<path>[^"]+)"(?:\s+\[[^\]]+\])?\s+for\s+(?<ip>\S+)\s+at\s+(?<at>.+)\z/
34
+ ],
35
+ [
36
+ :processing,
37
+ /\AProcessing by\s+(?<controller>\S+)\s+as\s+(?<format>\S+)\z/
38
+ ],
39
+ [
40
+ :parameters,
41
+ /\A\s*Parameters:\s*(?<params>.+)\z/
42
+ ],
43
+ [
44
+ :sql_source,
45
+ /\A\s*↳\s+(?<source>.+)\z/
46
+ ],
47
+ [
48
+ :sql,
49
+ /\A\s+(?<label>[A-Z][A-Za-z0-9_:]*\s+(?:Load|Count|Exists\??|Pluck|Update|Insert|Create|Destroy|Delete|Maximum|Minimum|Sum|Average))\s+\((?<duration>[\d.]+)(?<unit>ms|s)\)\s+(?<query>.+)\z/
50
+ ],
51
+ [
52
+ :sql,
53
+ /\A\s+(?<label>SQL|TRANSACTION|SCHEMA|CACHE)\s+\((?<duration>[\d.]+)(?<unit>ms|s)\)\s+(?<query>.+)\z/
54
+ ],
55
+ [
56
+ :render,
57
+ /\A\s*Rendered\s+(?<template>.+)\z/
58
+ ],
59
+ [
60
+ :cache,
61
+ /\ACACHE\s+(?<result>HIT|MISS)\s+(?<key>.+)\z/
62
+ ],
63
+ [
64
+ :request_completed,
65
+ /\ACompleted\s+(?<status>\d+)\s+(?<status_text>[A-Za-z ]+?)\s+in\s+(?<duration_ms>[\d.]+)ms(?:\s+\((?<details>[^)]+)\))?/
66
+ ],
67
+ [
68
+ :job_enqueued,
69
+ /\AEnqueued\s+(?<job>\S+)\s+\(Job ID:\s+(?<job_id>[\w-]+)\)\s+to\s+(?<adapter>[^(]+)\((?<queue>[^)]+)\)/
70
+ ],
71
+ [
72
+ :job_performing,
73
+ /\APerforming\s+(?<job>\S+)\s+\(Job ID:\s+(?<job_id>[\w-]+)\)\s+from\s+(?<adapter>[^(]+)\((?<queue>[^)]+)\)/
74
+ ],
75
+ [
76
+ :job_performed,
77
+ /\APerformed\s+(?<job>\S+)\s+\(Job ID:\s+(?<job_id>[\w-]+)\)\s+from\s+(?<adapter>[^(]+)\((?<queue>[^)]+)\)\s+in\s+(?<duration_ms>[\d.]+)ms/
78
+ ],
79
+ [
80
+ :job_retry_stopped,
81
+ /\AStopped retrying\s+(?<job>\S+)\s+\(Job ID:\s+(?<job_id>[\w-]+)\)/
82
+ ]
83
+ ].freeze
84
+
85
+ def initialize
86
+ @untagged_seq = 0
87
+ @open_untagged_http = nil
88
+ end
89
+
90
+ def parse(raw_line)
91
+ raw = raw_line.chomp
92
+ plain = Ansi.strip(raw)
93
+ tag_match = plain.match(TAG_RE)
94
+ tag_prefix_len = tag_match ? tag_match[0].length : 0
95
+ tags = tag_match ? tag_match[0].scan(/\[([^\]]*)\]/).flatten : []
96
+
97
+ raw_body = raw[tag_prefix_len..] || ""
98
+ plain_body = plain[tag_prefix_len..] || ""
99
+
100
+ group_id, group_kind, title = identify_group(tags)
101
+ type, payload = classify(plain_body)
102
+
103
+ group_id, group_kind, title = adjust_for_untagged(group_id, group_kind, title, type, payload)
104
+
105
+ title ||= derive_title(type, payload)
106
+
107
+ # An HTTP request to /cable is ActionCable. Treat it as its own kind so
108
+ # the UI can filter it out — these connections are long-lived and noisy.
109
+ if group_kind == :http && cable_path?(title, payload)
110
+ group_kind = :cable
111
+ end
112
+
113
+ Event.new(
114
+ group_id: group_id,
115
+ group_kind: group_kind,
116
+ group_title: title,
117
+ type: type,
118
+ payload: payload,
119
+ raw: raw_body,
120
+ plain: plain_body
121
+ )
122
+ end
123
+
124
+ private
125
+
126
+ def identify_group(tags)
127
+ return [nil, :untagged, nil] if tags.empty?
128
+
129
+ if tags.first == "ActiveJob" && tags.length >= 3
130
+ job_class = tags[1]
131
+ job_uuid = tags[2]
132
+ return ["job:#{job_uuid}", :job, job_class]
133
+ end
134
+
135
+ if tags.length == 1 && tags.first.match?(UUID_RE)
136
+ return ["http:#{tags.first}", :http, nil]
137
+ end
138
+
139
+ [nil, :untagged, nil]
140
+ end
141
+
142
+ def classify(body)
143
+ CLASSIFIERS.each do |type, regex|
144
+ m = body.match(regex)
145
+ next unless m
146
+
147
+ payload = m.named_captures.transform_keys(&:to_sym)
148
+ payload = normalise_payload(type, payload)
149
+ return [type, payload]
150
+ end
151
+
152
+ [:other, {}]
153
+ end
154
+
155
+ def normalise_payload(type, payload)
156
+ case type
157
+ when :sql
158
+ if payload[:unit] == "s" && payload[:duration]
159
+ payload[:duration_ms] = (payload[:duration].to_f * 1000).round(2)
160
+ elsif payload[:duration]
161
+ payload[:duration_ms] = payload[:duration].to_f
162
+ end
163
+ payload.delete(:unit)
164
+ payload.delete(:duration)
165
+ when :request_completed, :job_performed
166
+ payload[:duration_ms] = payload[:duration_ms].to_f if payload[:duration_ms]
167
+ payload[:status] = payload[:status].to_i if payload[:status]
168
+ end
169
+ payload
170
+ end
171
+
172
+ def adjust_for_untagged(group_id, group_kind, title, type, payload)
173
+ return [group_id, group_kind, title] unless group_kind == :untagged
174
+
175
+ case type
176
+ when :request_started
177
+ @untagged_seq += 1
178
+ @open_untagged_http = "http_synth:#{@untagged_seq}"
179
+ ["#{@open_untagged_http}", :http, "#{payload[:method]} #{payload[:path]}"]
180
+ when :request_completed
181
+ if @open_untagged_http
182
+ finishing = @open_untagged_http
183
+ @open_untagged_http = nil
184
+ [finishing, :http, nil]
185
+ else
186
+ ["untagged", :untagged, nil]
187
+ end
188
+ else
189
+ if @open_untagged_http
190
+ [@open_untagged_http, :http, nil]
191
+ else
192
+ ["untagged", :untagged, nil]
193
+ end
194
+ end
195
+ end
196
+
197
+ def derive_title(type, payload)
198
+ case type
199
+ when :request_started
200
+ "#{payload[:method]} #{payload[:path]}" if payload[:method]
201
+ when :job_performing, :job_performed, :job_enqueued, :job_retry_stopped
202
+ payload[:job]
203
+ end
204
+ end
205
+
206
+ def cable_path?(title, payload)
207
+ path = payload[:path] || title&.split(" ", 2)&.last
208
+ path&.start_with?("/cable")
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,198 @@
1
+ (() => {
2
+ const listEl = document.getElementById("list");
3
+ const detailEl = document.getElementById("detail");
4
+ const filterEl = document.getElementById("filter");
5
+ const statusEl = document.getElementById("status");
6
+ const rowTpl = document.getElementById("row-template");
7
+ const kindButtons = document.querySelectorAll(".kind-toggle button");
8
+
9
+ const state = {
10
+ groups: new Map(), // id -> summary
11
+ order: [], // newest-first
12
+ selectedId: null,
13
+ // Kinds the user has toggled on. Items with a kind not in this set are
14
+ // hidden. "untagged" has no toggle and is always shown.
15
+ enabledKinds: new Set(["http", "job", "cable"]),
16
+ filter: ""
17
+ };
18
+
19
+ function statusClass(status, kind) {
20
+ if (status === "stopped") return "err";
21
+ if (typeof status === "number") {
22
+ if (status >= 500) return "err";
23
+ if (status >= 400) return "warn";
24
+ if (status >= 200) return "ok";
25
+ }
26
+ if (status === "ok") return "ok";
27
+ return "";
28
+ }
29
+
30
+ function fmtDuration(ms) {
31
+ if (ms == null) return "";
32
+ if (ms < 1) return `${ms.toFixed(2)}ms`;
33
+ if (ms < 1000) return `${ms.toFixed(1)}ms`;
34
+ return `${(ms / 1000).toFixed(2)}s`;
35
+ }
36
+
37
+ function rowMatches(summary) {
38
+ // Untagged items have no toggle button and are always shown.
39
+ if (summary.kind !== "untagged" && !state.enabledKinds.has(summary.kind)) return false;
40
+ if (state.filter && !(summary.title || "").toLowerCase().includes(state.filter)) return false;
41
+ return true;
42
+ }
43
+
44
+ function renderList() {
45
+ listEl.innerHTML = "";
46
+ for (const id of state.order) {
47
+ const summary = state.groups.get(id);
48
+ if (!summary) continue;
49
+ if (!rowMatches(summary)) continue;
50
+
51
+ const frag = rowTpl.content.cloneNode(true);
52
+ const row = frag.querySelector(".row");
53
+ row.dataset.id = id;
54
+ row.dataset.kind = summary.kind;
55
+ row.dataset.statusClass = statusClass(summary.status, summary.kind);
56
+ if (id === state.selectedId) row.classList.add("active");
57
+
58
+ row.querySelector(".glyph").textContent =
59
+ summary.kind === "job" ? "⚙" :
60
+ summary.kind === "cable" ? "≋" : "▶";
61
+ row.querySelector(".title").textContent = summary.title || id;
62
+ row.querySelector(".status-pill").textContent = summary.status ?? (summary.finished_at ? "" : "…");
63
+ row.querySelector(".duration").textContent = fmtDuration(summary.duration_ms);
64
+ row.querySelector(".count").textContent = `${summary.line_count} lines`;
65
+ row.addEventListener("click", () => selectGroup(id));
66
+ listEl.appendChild(row);
67
+ }
68
+ }
69
+
70
+ function updateRow(summary) {
71
+ const row = listEl.querySelector(`.row[data-id="${CSS.escape(summary.id)}"]`);
72
+ if (!row) return;
73
+ row.dataset.statusClass = statusClass(summary.status, summary.kind);
74
+ row.querySelector(".title").textContent = summary.title || summary.id;
75
+ row.querySelector(".status-pill").textContent = summary.status ?? (summary.finished_at ? "" : "…");
76
+ row.querySelector(".duration").textContent = fmtDuration(summary.duration_ms);
77
+ row.querySelector(".count").textContent = `${summary.line_count} lines`;
78
+ }
79
+
80
+ function selectGroup(id) {
81
+ state.selectedId = id;
82
+ for (const row of listEl.querySelectorAll(".row")) {
83
+ row.classList.toggle("active", row.dataset.id === id);
84
+ }
85
+ fetch(`/groups/${encodeURIComponent(id)}`).then(r => r.json()).then(({ group }) => {
86
+ if (!group || state.selectedId !== id) return;
87
+ renderDetail(group);
88
+ });
89
+ }
90
+
91
+ function renderDetail(group) {
92
+ const head = document.createElement("div");
93
+ head.innerHTML = `<h2></h2><div class="subhead"></div>`;
94
+ head.querySelector("h2").textContent = group.title || group.id;
95
+ head.querySelector(".subhead").textContent =
96
+ `${group.kind} · ${group.line_count} lines · ${fmtDuration(group.duration_ms)} · ${group.status ?? "(in flight)"} · ${group.id}`;
97
+
98
+ const lines = document.createElement("div");
99
+ lines.className = "lines";
100
+
101
+ for (const line of group.lines) {
102
+ const lineEl = document.createElement("div");
103
+ lineEl.className = "line";
104
+ lineEl.dataset.type = line.type;
105
+ const badge = document.createElement("span");
106
+ badge.className = "badge";
107
+ badge.textContent = line.type;
108
+ const body = document.createElement("span");
109
+ body.className = "body";
110
+ body.innerHTML = line.html;
111
+ lineEl.appendChild(badge);
112
+ lineEl.appendChild(body);
113
+
114
+ if (line.type === "sql") {
115
+ lineEl.addEventListener("click", () => lineEl.classList.toggle("open"));
116
+ }
117
+ lines.appendChild(lineEl);
118
+ }
119
+
120
+ detailEl.innerHTML = "";
121
+ detailEl.appendChild(head);
122
+ detailEl.appendChild(lines);
123
+ }
124
+
125
+ function applySummary(summary, { prepend = false } = {}) {
126
+ const existing = state.groups.has(summary.id);
127
+ state.groups.set(summary.id, summary);
128
+ if (!existing) {
129
+ if (prepend) state.order.unshift(summary.id);
130
+ else state.order.push(summary.id);
131
+ }
132
+ if (existing) {
133
+ updateRow(summary);
134
+ } else {
135
+ renderList();
136
+ }
137
+ if (state.selectedId === summary.id) {
138
+ fetch(`/groups/${encodeURIComponent(summary.id)}`).then(r => r.json()).then(({ group }) => {
139
+ if (group && state.selectedId === summary.id) renderDetail(group);
140
+ });
141
+ }
142
+ }
143
+
144
+ function loadInitial() {
145
+ fetch("/groups?limit=500").then(r => r.json()).then(({ groups }) => {
146
+ state.order = groups.map(g => g.id);
147
+ state.groups = new Map(groups.map(g => [g.id, g]));
148
+ renderList();
149
+ });
150
+ }
151
+
152
+ function connectStream() {
153
+ const es = new EventSource("/events");
154
+ es.addEventListener("open", () => {
155
+ statusEl.textContent = "live";
156
+ statusEl.className = "status connected";
157
+ });
158
+ es.addEventListener("error", () => {
159
+ statusEl.textContent = "disconnected";
160
+ statusEl.className = "status error";
161
+ });
162
+ const onUpdate = (e) => {
163
+ const summary = JSON.parse(e.data);
164
+ applySummary(summary, { prepend: e.type === "group_created" });
165
+ };
166
+ es.addEventListener("group_created", onUpdate);
167
+ es.addEventListener("group_updated", onUpdate);
168
+ es.addEventListener("group_completed", onUpdate);
169
+ es.addEventListener("group_evicted", (e) => {
170
+ const { id } = JSON.parse(e.data);
171
+ state.groups.delete(id);
172
+ state.order = state.order.filter(x => x !== id);
173
+ renderList();
174
+ });
175
+ }
176
+
177
+ filterEl.addEventListener("input", () => {
178
+ state.filter = filterEl.value.trim().toLowerCase();
179
+ renderList();
180
+ });
181
+
182
+ kindButtons.forEach(btn => {
183
+ btn.addEventListener("click", () => {
184
+ const kind = btn.dataset.kind;
185
+ if (state.enabledKinds.has(kind)) {
186
+ state.enabledKinds.delete(kind);
187
+ } else {
188
+ state.enabledKinds.add(kind);
189
+ }
190
+ btn.classList.toggle("active", state.enabledKinds.has(kind));
191
+ btn.setAttribute("aria-pressed", state.enabledKinds.has(kind).toString());
192
+ renderList();
193
+ });
194
+ });
195
+
196
+ loadInitial();
197
+ connectStream();
198
+ })();
@@ -0,0 +1,40 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>llv — local log viewer</title>
6
+ <link rel="stylesheet" href="/styles.css" />
7
+ </head>
8
+ <body>
9
+ <header>
10
+ <h1>llv</h1>
11
+ <div class="filters">
12
+ <input type="search" id="filter" placeholder="filter title…" />
13
+ <div class="kind-toggle" role="group" aria-label="kind filters">
14
+ <button data-kind="http" class="active" aria-pressed="true">http</button>
15
+ <button data-kind="job" class="active" aria-pressed="true">jobs</button>
16
+ <button data-kind="cable" class="active" aria-pressed="true">cable</button>
17
+ </div>
18
+ <span class="status" id="status">connecting…</span>
19
+ </div>
20
+ </header>
21
+ <main>
22
+ <aside id="list" aria-label="requests"></aside>
23
+ <section id="detail">
24
+ <div class="empty">select an item on the left</div>
25
+ </section>
26
+ </main>
27
+ <template id="row-template">
28
+ <button class="row" type="button">
29
+ <span class="glyph"></span>
30
+ <span class="title"></span>
31
+ <span class="meta">
32
+ <span class="status-pill"></span>
33
+ <span class="duration"></span>
34
+ <span class="count"></span>
35
+ </span>
36
+ </button>
37
+ </template>
38
+ <script src="/app.js"></script>
39
+ </body>
40
+ </html>
@@ -0,0 +1,239 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0f1115;
4
+ --panel: #161922;
5
+ --panel-2: #1d2230;
6
+ --border: #262b3a;
7
+ --text: #d7dae0;
8
+ --muted: #7b8190;
9
+ --accent: #6cbcff;
10
+ --ok: #6fcf97;
11
+ --warn: #f2c14e;
12
+ --err: #ef6c6c;
13
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+
18
+ html, body {
19
+ height: 100%;
20
+ margin: 0;
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ font: 13px/1.5 var(--mono);
24
+ }
25
+
26
+ header {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 16px;
30
+ padding: 10px 14px;
31
+ border-bottom: 1px solid var(--border);
32
+ background: var(--panel);
33
+ }
34
+
35
+ header h1 {
36
+ margin: 0;
37
+ font-size: 14px;
38
+ font-weight: 700;
39
+ letter-spacing: 0.08em;
40
+ text-transform: uppercase;
41
+ color: var(--accent);
42
+ }
43
+
44
+ .filters {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 12px;
48
+ flex: 1;
49
+ }
50
+
51
+ .filters input[type="search"] {
52
+ flex: 1;
53
+ background: var(--panel-2);
54
+ border: 1px solid var(--border);
55
+ color: var(--text);
56
+ padding: 6px 10px;
57
+ border-radius: 6px;
58
+ font: inherit;
59
+ }
60
+
61
+ .kind-toggle {
62
+ display: inline-flex;
63
+ border: 1px solid var(--border);
64
+ border-radius: 6px;
65
+ overflow: hidden;
66
+ }
67
+
68
+ .kind-toggle button {
69
+ background: transparent;
70
+ border: 0;
71
+ color: var(--muted);
72
+ padding: 6px 10px;
73
+ font: inherit;
74
+ cursor: pointer;
75
+ }
76
+
77
+ .kind-toggle button.active {
78
+ background: var(--panel-2);
79
+ color: var(--text);
80
+ }
81
+
82
+ .status { color: var(--muted); font-size: 12px; }
83
+ .status.connected { color: var(--ok); }
84
+ .status.error { color: var(--err); }
85
+
86
+ main {
87
+ display: grid;
88
+ grid-template-columns: 360px 1fr;
89
+ height: calc(100vh - 49px);
90
+ }
91
+
92
+ #list {
93
+ border-right: 1px solid var(--border);
94
+ overflow-y: auto;
95
+ background: var(--panel);
96
+ }
97
+
98
+ .row {
99
+ display: grid;
100
+ grid-template-columns: 18px 1fr auto;
101
+ gap: 6px 10px;
102
+ align-items: center;
103
+ width: 100%;
104
+ text-align: left;
105
+ background: transparent;
106
+ border: 0;
107
+ border-bottom: 1px solid var(--border);
108
+ color: var(--text);
109
+ padding: 8px 12px;
110
+ font: inherit;
111
+ cursor: pointer;
112
+ }
113
+
114
+ .row:hover { background: var(--panel-2); }
115
+ .row.active { background: var(--panel-2); box-shadow: inset 3px 0 0 var(--accent); }
116
+
117
+ .row .glyph {
118
+ color: var(--accent);
119
+ text-align: center;
120
+ }
121
+
122
+ .row[data-kind="job"] .glyph { color: var(--warn); }
123
+ .row[data-kind="cable"] .glyph { color: var(--muted); }
124
+ .row[data-kind="cable"] { opacity: 0.85; }
125
+
126
+ .row .title {
127
+ white-space: nowrap;
128
+ overflow: hidden;
129
+ text-overflow: ellipsis;
130
+ }
131
+
132
+ .row .meta {
133
+ display: flex;
134
+ gap: 6px;
135
+ align-items: center;
136
+ color: var(--muted);
137
+ font-size: 11px;
138
+ grid-column: 2 / 4;
139
+ margin-top: 2px;
140
+ }
141
+
142
+ .status-pill {
143
+ padding: 1px 6px;
144
+ border-radius: 8px;
145
+ background: var(--panel-2);
146
+ border: 1px solid var(--border);
147
+ }
148
+
149
+ .row[data-status-class="ok"] .status-pill { color: var(--ok); border-color: var(--ok); }
150
+ .row[data-status-class="warn"] .status-pill { color: var(--warn); border-color: var(--warn); }
151
+ .row[data-status-class="err"] .status-pill { color: var(--err); border-color: var(--err); }
152
+
153
+ #detail {
154
+ overflow-y: auto;
155
+ padding: 14px 18px;
156
+ }
157
+
158
+ #detail .empty {
159
+ color: var(--muted);
160
+ text-align: center;
161
+ padding: 80px 0;
162
+ }
163
+
164
+ #detail h2 {
165
+ margin: 0 0 4px;
166
+ font-size: 14px;
167
+ }
168
+
169
+ #detail .subhead {
170
+ color: var(--muted);
171
+ font-size: 12px;
172
+ margin-bottom: 16px;
173
+ }
174
+
175
+ .lines {
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 2px;
179
+ }
180
+
181
+ .line {
182
+ display: grid;
183
+ grid-template-columns: 90px 1fr;
184
+ gap: 10px;
185
+ padding: 3px 6px;
186
+ border-radius: 3px;
187
+ white-space: pre-wrap;
188
+ word-break: break-word;
189
+ }
190
+
191
+ .line:hover { background: var(--panel); }
192
+
193
+ .line .badge {
194
+ color: var(--muted);
195
+ font-size: 10px;
196
+ letter-spacing: 0.05em;
197
+ text-transform: uppercase;
198
+ padding-top: 2px;
199
+ }
200
+
201
+ .line[data-type="sql"] .badge { color: var(--accent); }
202
+ .line[data-type="sql_source"] .badge { color: var(--muted); }
203
+ .line[data-type="render"] .badge { color: var(--ok); }
204
+ .line[data-type="request_completed"] .badge,
205
+ .line[data-type="job_performed"] .badge { color: var(--ok); }
206
+ .line[data-type="job_retry_stopped"] .badge { color: var(--err); }
207
+
208
+ .line[data-type="sql_source"] {
209
+ display: none;
210
+ color: var(--muted);
211
+ font-size: 11px;
212
+ }
213
+
214
+ .line[data-type="sql"].open + .line[data-type="sql_source"] { display: grid; }
215
+
216
+ .line[data-type="sql"] { cursor: pointer; }
217
+ .line[data-type="sql"] .body::before { content: "▸ "; color: var(--muted); }
218
+ .line[data-type="sql"].open .body::before { content: "▾ "; }
219
+
220
+ /* ANSI colour classes — reuse Rails' own SGR output */
221
+ .ansi-bold { font-weight: 700; }
222
+ .ansi-italic { font-style: italic; }
223
+ .ansi-underline { text-decoration: underline; }
224
+ .ansi-fg-black { color: #5c6370; }
225
+ .ansi-fg-red { color: #ef6c6c; }
226
+ .ansi-fg-green { color: #6fcf97; }
227
+ .ansi-fg-yellow { color: #f2c14e; }
228
+ .ansi-fg-blue { color: #6cbcff; }
229
+ .ansi-fg-magenta { color: #c792ea; }
230
+ .ansi-fg-cyan { color: #56b6c2; }
231
+ .ansi-fg-white { color: #d7dae0; }
232
+ .ansi-fg-bright-black { color: #7b8190; }
233
+ .ansi-fg-bright-red { color: #ff8888; }
234
+ .ansi-fg-bright-green { color: #98e6b1; }
235
+ .ansi-fg-bright-yellow { color: #ffd87a; }
236
+ .ansi-fg-bright-blue { color: #8fd0ff; }
237
+ .ansi-fg-bright-magenta { color: #e0b6ff; }
238
+ .ansi-fg-bright-cyan { color: #88d8e0; }
239
+ .ansi-fg-bright-white { color: #ffffff; }