Line data Source code
1 : /*
2 : * Copyright (C) 2015 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 203 : import cockpit from "cockpit";
7 : import * as timeformat from "timeformat";
8 :
9 203 : const _ = cockpit.gettext;
10 :
11 203 : export const journal = { };
12 :
13 : /**
14 : * journalctl([match, ...], [options])
15 : * @match: any number of journal match strings
16 : * @options: an object containing further options
17 : *
18 : * Load and (by default) stream journal entries as
19 : * json objects. This function returns a jQuery deferred
20 : * object which delivers the various journal entries.
21 : *
22 : * The various @match strings are journalctl matches.
23 : * Zero, one or more can be specified. They must be in
24 : * string format, or arrays of strings.
25 : *
26 : * The optional @options object can contain the following:
27 : * * "count": number of entries to load and/or pre-stream.
28 : * Default is 10
29 : * * "follow": if set to false just load entries and don't
30 : * stream further journal data. Default is true.
31 : * * "directory": optional directory to load journal files
32 : * * "boot": when set only list entries from this specific
33 : * boot id, or if null then the current boot.
34 : * * "since": if specified list entries since the date/time
35 : * * "until": if specified list entries until the date/time
36 : * * "cursor": a cursor to start listing entries from
37 : * * "after": a cursor to start listing entries after
38 : * * "priority": if specified list entries below the specific priority, inclusive
39 : *
40 : * Returns a jQuery deferred promise. You can call these
41 : * functions on the deferred to handle the responses. Note that
42 : * there are additional non-jQuery methods.
43 : *
44 : * .done(function(entries) { }): Called when done, @entries is
45 : * an array of all journal entries loaded. If .stream()
46 : * has been invoked then @entries will be empty.
47 : * .fail(function(ex) { }): called if the operation fails
48 : * .stream(function(entries) { }): called when we receive entries
49 : * entries. Called once per batch of journal @entries,
50 : * whether following or not.
51 : * .stop(): stop following or retrieving entries.
52 : */
53 :
54 163 : journal.build_cmd = function build_cmd(/* ... */) {
55 163 : const matches = [];
56 163 : const options = { follow: true };
57 163 : for (let i = 0; i < arguments.length; i++) {
58 163 : const arg = arguments[i];
59 14 : if (typeof arg == "string") {
60 14 : matches.push(arg);
61 14 : } else if (typeof arg == "object") {
62 163 : if (arg instanceof Array) {
63 163 : matches.push.apply(matches, arg);
64 163 : } else {
65 163 : Object.assign(options, arg);
66 163 : break;
67 163 : }
68 14 : } else {
69 14 : console.warn("journal.journalctl called with invalid argument:", arg);
70 14 : }
71 163 : }
72 :
73 23 : if (options.count === undefined) {
74 23 : if (options.follow)
75 14 : options.count = 10;
76 : else
77 23 : options.count = null;
78 23 : }
79 :
80 163 : const cmd = ["journalctl", "-q"];
81 163 : if (!options.count)
82 19 : cmd.push("--no-tail");
83 : else
84 159 : cmd.push("--lines=" + options.count);
85 :
86 163 : cmd.push("--output=" + (options.output || "json"));
87 :
88 163 : if (options.directory)
89 14 : cmd.push("--directory=" + options.directory);
90 163 : if (options.boot)
91 15 : cmd.push("--boot=" + options.boot);
92 163 : else if (options.boot !== undefined)
93 14 : cmd.push("--boot");
94 163 : if (options.since)
95 23 : cmd.push("--since=" + options.since);
96 163 : if (options.until)
97 16 : cmd.push("--until=" + options.until);
98 163 : if (options.cursor)
99 23 : cmd.push("--cursor=" + options.cursor);
100 163 : if (options.after)
101 14 : cmd.push("--after=" + options.after);
102 163 : if (options.priority)
103 23 : cmd.push("--priority=" + options.priority);
104 163 : if (options.grep)
105 15 : cmd.push("--grep=" + options.grep);
106 :
107 : /* journalctl doesn't allow reverse and follow together */
108 163 : if (options.reverse)
109 23 : cmd.push("--reverse");
110 163 : else if (options.follow)
111 163 : cmd.push("--follow");
112 :
113 163 : cmd.push("--");
114 163 : cmd.push.apply(cmd, matches);
115 163 : return cmd;
116 163 : };
117 :
118 163 : journal.journalctl = function journalctl(/* ... */) {
119 163 : const cmd = journal.build_cmd.apply(null, arguments);
120 :
121 163 : const dfd = cockpit.defer();
122 163 : const promise = dfd.promise();
123 163 : let buffer = "";
124 163 : let entries = [];
125 163 : let streamers = [];
126 163 : let interval = null;
127 :
128 155 : function fire_streamers() {
129 155 : let ents;
130 155 : let i;
131 122 : if (streamers.length && entries.length > 0) {
132 122 : ents = entries;
133 122 : entries = [];
134 122 : for (i = 0; i < streamers.length; i++)
135 122 : streamers[i].apply(promise, [ents]);
136 122 : } else {
137 155 : window.clearInterval(interval);
138 155 : interval = null;
139 155 : }
140 155 : }
141 :
142 163 : const proc = cockpit.spawn(cmd, { batch: 8192, latency: 300, superuser: "try" })
143 159 : .stream(function(data) {
144 159 : if (buffer)
145 109 : data = buffer + data;
146 159 : buffer = "";
147 :
148 159 : const lines = data.split("\n");
149 159 : const last = lines.length - 1;
150 159 : lines.forEach(function(line, i) {
151 159 : if (i == last) {
152 159 : buffer = line;
153 159 : } else if (line && line.indexOf("-- ") !== 0) {
154 159 : try {
155 159 : entries.push(JSON.parse(line));
156 13 : } catch (e) {
157 13 : console.warn(e, line);
158 13 : }
159 159 : }
160 159 : });
161 :
162 159 : if (streamers.length && interval === null)
163 159 : interval = window.setInterval(fire_streamers, 300);
164 159 : })
165 12 : .done(function() {
166 12 : fire_streamers();
167 12 : dfd.resolve(entries);
168 12 : })
169 128 : .fail(function(ex) {
170 : /* The journalctl command fails when no entries are matched
171 : * so we just ignore this status code */
172 128 : if (ex.problem == "cancelled" ||
173 3 : ex.exit_status === 1) {
174 128 : fire_streamers();
175 128 : dfd.resolve(entries);
176 2 : } else {
177 2 : dfd.reject(ex);
178 2 : }
179 128 : })
180 131 : .always(function() {
181 131 : window.clearInterval(interval);
182 131 : });
183 :
184 163 : promise.stream = function stream(callback) {
185 163 : streamers.push(callback);
186 163 : return this;
187 163 : };
188 129 : promise.stop = function stop() {
189 129 : streamers = [];
190 129 : promise.stopped = true;
191 129 : proc.close("cancelled");
192 129 : };
193 163 : return promise;
194 163 : };
195 :
196 122 : journal.printable = function printable(value, key) {
197 122 : if (value === undefined || value === null)
198 11 : return _("[no data]");
199 122 : else if (typeof (value) == "string")
200 11 : return value;
201 11 : else if (value.length !== undefined && value.length <= 1000 && key == "MESSAGE")
202 11 : return new TextDecoder().decode(new Uint8Array(value));
203 11 : else {
204 11 : return _("[binary data]");
205 11 : }
206 122 : };
207 :
208 : /* Render the journal entries by passing suitable DOM elements back to
209 : the caller via the 'output_funcs'.
210 :
211 : Rendering is context aware. It will insert 'reboot' markers, for
212 : example, and collapse repeated lines. You can extend the output at
213 : the bottom and also at the top.
214 :
215 : A new renderer is created by calling 'journal.renderer' like
216 : so:
217 :
218 : const renderer = journal.renderer(funcs);
219 :
220 : You can feed new entries into the renderer by calling various
221 : methods on the returned object:
222 :
223 : - renderer.append(journal_entry)
224 : - renderer.append_flush()
225 : - renderer.prepend(journal_entry)
226 : - renderer.prepend_flush()
227 :
228 : A 'journal_entry' is one element of the result array returned by a
229 : call to 'Query' with the 'cockpit.journal_fields' as the fields to
230 : return.
231 :
232 : Calling 'append' will append the given entry to the end of the
233 : output, naturally, and 'prepend' will prepend it to the start.
234 :
235 : The output might lag behind what has been input via 'append' and
236 : 'prepend', and you need to call 'append_flush' and 'prepend_flush'
237 : respectively to ensure that the output is up-to-date. Flushing a
238 : renderer does not introduce discontinuities into the output. You
239 : can continue to feed entries into the renderer after flushing and
240 : repeated lines will be correctly collapsed across the flush, for
241 : example.
242 :
243 : The renderer will call methods of the 'output_funcs' object to
244 : produce the desired output:
245 :
246 : - output_funcs.append(rendered)
247 : - output_funcs.remove_last()
248 : - output_funcs.prepend(rendered)
249 : - output_funcs.remove_first()
250 :
251 : The 'rendered' argument is the return value of one of the rendering
252 : functions described below. The 'append' and 'prepend' methods
253 : should add this element to the output, naturally, and 'remove_last'
254 : and 'remove_first' should remove the indicated element.
255 :
256 : If you never call 'prepend' on the renderer, 'output_func.prepend'
257 : isn't called either. If you never call 'renderer.prepend' after
258 : 'renderer.prepend_flush', then 'output_func.remove_first' will
259 : never be called. The same guarantees exist for the 'append' family
260 : of functions.
261 :
262 : The actual rendering is also done by calling methods on
263 : 'output_funcs':
264 :
265 : - output_funcs.render_line(ident, prio, message, count, time, cursor)
266 : - output_funcs.render_day_header(day)
267 : - output_funcs.render_reboot_separator()
268 : */
269 :
270 163 : journal.renderer = function renderer(output_funcs) {
271 163 : if (!output_funcs.render_line)
272 14 : console.error("Invalid renderer provided");
273 :
274 113 : function copy_object(o) {
275 113 : const c = { }; for (const p in o) c[p] = o[p]; return c;
276 113 : }
277 :
278 : // A 'entry' object describes a journal entry in formatted form.
279 : // It has fields 'bootid', 'ident', 'prio', 'message', 'time',
280 : // 'day', all of which are strings.
281 :
282 122 : function format_entry(journal_entry) {
283 122 : const d = journal_entry.__REALTIME_TIMESTAMP / 1000; // timestamps are in µs
284 122 : return {
285 122 : cursor: journal_entry.__CURSOR,
286 122 : full: journal_entry,
287 122 : day: timeformat.date(d),
288 122 : time: timeformat.time(d),
289 122 : bootid: journal_entry._BOOT_ID,
290 69 : ident: journal_entry.SYSLOG_IDENTIFIER || journal_entry._COMM,
291 122 : prio: journal_entry.PRIORITY,
292 122 : message: journal.printable(journal_entry.MESSAGE, "MESSAGE")
293 122 : };
294 122 : }
295 :
296 122 : function entry_is_equal(a, b) {
297 113 : return (a && b &&
298 113 : a.day == b.day &&
299 113 : a.bootid == b.bootid &&
300 113 : a.ident == b.ident &&
301 112 : a.prio == b.prio &&
302 110 : a.message == b.message);
303 122 : }
304 :
305 : // A state object describes a line that should be eventually
306 : // output. It has an 'entry' field as per description above, and
307 : // also 'count', 'last_time', and 'first_time', which record
308 : // repeated entries. Additionally:
309 : //
310 : // line_present: When true, the line has been output already with
311 : // some preliminary data. It needs to be removed before
312 : // outputting more recent data.
313 : //
314 : // header_present: The day header has been output preliminarily
315 : // before the actual log lines. It needs to be removed before
316 : // prepending more lines. If both line_present and
317 : // header_present are true, then the header comes first in the
318 : // output, followed by the line.
319 :
320 122 : function render_state_line(state) {
321 122 : return output_funcs.render_line(state.entry.ident,
322 122 : state.entry.prio,
323 122 : state.entry.message,
324 122 : state.count,
325 122 : state.last_time,
326 122 : state.entry.full);
327 122 : }
328 :
329 : // We keep the state of the first and last journal lines,
330 : // respectively, in order to collapse repeated lines, and to
331 : // insert reboot markers and day headers.
332 : //
333 : // Normally, there are two state objects, but if only a single
334 : // line has been output so far, top_state and bottom_state point
335 : // to the same object.
336 :
337 163 : let top_state;
338 163 : let bottom_state;
339 :
340 163 : top_state = bottom_state = { };
341 :
342 122 : function start_new_line() {
343 : // If we now have two lines, split the state
344 113 : if (top_state === bottom_state && top_state.entry) {
345 113 : top_state = copy_object(bottom_state);
346 113 : }
347 122 : }
348 :
349 121 : function top_output() {
350 48 : if (top_state.header_present) {
351 48 : output_funcs.remove_first();
352 48 : top_state.header_present = false;
353 48 : }
354 48 : if (top_state.line_present) {
355 48 : output_funcs.remove_first();
356 48 : top_state.line_present = false;
357 48 : }
358 121 : if (top_state.entry) {
359 121 : output_funcs.prepend(render_state_line(top_state));
360 121 : top_state.line_present = true;
361 121 : }
362 121 : }
363 :
364 113 : function prepend(journal_entry) {
365 113 : const entry = format_entry(journal_entry);
366 :
367 12 : if (entry_is_equal(top_state.entry, entry)) {
368 12 : top_state.count += 1;
369 12 : top_state.first_time = entry.time;
370 12 : } else {
371 113 : top_output();
372 :
373 104 : if (top_state.entry) {
374 104 : if (entry.bootid != top_state.entry.bootid)
375 8 : output_funcs.prepend(output_funcs.render_reboot_separator());
376 104 : if (entry.day != top_state.entry.day)
377 8 : output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
378 104 : }
379 :
380 113 : start_new_line();
381 113 : top_state.entry = entry;
382 113 : top_state.count = 1;
383 113 : top_state.first_time = top_state.last_time = entry.time;
384 113 : top_state.line_present = false;
385 113 : }
386 113 : }
387 :
388 121 : function prepend_flush() {
389 121 : top_output();
390 121 : if (top_state.entry) {
391 121 : output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
392 121 : top_state.header_present = true;
393 121 : }
394 121 : }
395 :
396 11 : function bottom_output() {
397 6 : if (bottom_state.line_present) {
398 6 : output_funcs.remove_last();
399 6 : bottom_state.line_present = false;
400 6 : }
401 11 : if (bottom_state.entry) {
402 11 : output_funcs.append(render_state_line(bottom_state));
403 11 : bottom_state.line_present = true;
404 11 : }
405 11 : }
406 :
407 11 : function append(journal_entry) {
408 11 : const entry = format_entry(journal_entry);
409 :
410 6 : if (entry_is_equal(bottom_state.entry, entry)) {
411 6 : bottom_state.count += 1;
412 6 : bottom_state.last_time = entry.time;
413 6 : } else {
414 11 : bottom_output();
415 :
416 10 : if (!bottom_state.entry || entry.day != bottom_state.entry.day) {
417 11 : output_funcs.append(output_funcs.render_day_header(entry.day));
418 11 : bottom_state.header_present = true;
419 11 : }
420 10 : if (bottom_state.entry && entry.bootid != bottom_state.entry.bootid)
421 2 : output_funcs.append(output_funcs.render_reboot_separator());
422 :
423 11 : start_new_line();
424 11 : bottom_state.entry = entry;
425 11 : bottom_state.count = 1;
426 11 : bottom_state.first_time = bottom_state.last_time = entry.time;
427 11 : bottom_state.line_present = false;
428 11 : }
429 11 : }
430 :
431 11 : function append_flush() {
432 11 : bottom_output();
433 11 : }
434 :
435 163 : return {
436 163 : prepend,
437 163 : prepend_flush,
438 163 : append,
439 163 : append_flush
440 163 : };
441 163 : };
|