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.
- checksums.yaml +7 -0
- data/README.md +130 -0
- data/bin/llv +6 -0
- data/lib/llv/ansi.rb +154 -0
- data/lib/llv/cli.rb +121 -0
- data/lib/llv/group_store.rb +166 -0
- data/lib/llv/parser.rb +211 -0
- data/lib/llv/public/app.js +198 -0
- data/lib/llv/public/index.html +40 -0
- data/lib/llv/public/styles.css +239 -0
- data/lib/llv/tailer.rb +105 -0
- data/lib/llv/tui.rb +521 -0
- data/lib/llv/version.rb +5 -0
- data/lib/llv/web.rb +104 -0
- data/lib/llv.rb +8 -0
- metadata +153 -0
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; }
|