LCOV - code coverage report
Current view: top level - pkg/lib - journal.js Coverage Total Hit
Test: cockpit Lines: 99.2 % 261 259
Test Date: 2026-06-17 06:28:00

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2015 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           53 : import cockpit from "cockpit";
       7              : import * as timeformat from "timeformat";
       8              : 
       9           53 : const _ = cockpit.gettext;
      10              : 
      11           53 : 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           34 : journal.build_cmd = function build_cmd(/* ... */) {
      55           34 :     const matches = [];
      56           34 :     const options = { follow: true };
      57           34 :     for (let i = 0; i < arguments.length; i++) {
      58           34 :         const arg = arguments[i];
      59            3 :         if (typeof arg == "string") {
      60            3 :             matches.push(arg);
      61            3 :         } else if (typeof arg == "object") {
      62           34 :             if (arg instanceof Array) {
      63           34 :                 matches.push.apply(matches, arg);
      64           34 :             } else {
      65           34 :                 Object.assign(options, arg);
      66           34 :                 break;
      67           34 :             }
      68            3 :         } else {
      69            3 :             console.warn("journal.journalctl called with invalid argument:", arg);
      70            3 :         }
      71           34 :     }
      72              : 
      73           12 :     if (options.count === undefined) {
      74           12 :         if (options.follow)
      75            3 :             options.count = 10;
      76              :         else
      77           12 :             options.count = null;
      78           12 :     }
      79              : 
      80           34 :     const cmd = ["journalctl", "-q"];
      81           34 :     if (!options.count)
      82            8 :         cmd.push("--no-tail");
      83              :     else
      84           30 :         cmd.push("--lines=" + options.count);
      85              : 
      86           34 :     cmd.push("--output=" + (options.output || "json"));
      87              : 
      88           34 :     if (options.directory)
      89            3 :         cmd.push("--directory=" + options.directory);
      90           34 :     if (options.boot)
      91            4 :         cmd.push("--boot=" + options.boot);
      92           34 :     else if (options.boot !== undefined)
      93            3 :         cmd.push("--boot");
      94           34 :     if (options.since)
      95           12 :         cmd.push("--since=" + options.since);
      96           34 :     if (options.until)
      97            5 :         cmd.push("--until=" + options.until);
      98           34 :     if (options.cursor)
      99           11 :         cmd.push("--cursor=" + options.cursor);
     100           34 :     if (options.after)
     101            3 :         cmd.push("--after=" + options.after);
     102           34 :     if (options.priority)
     103           12 :         cmd.push("--priority=" + options.priority);
     104           34 :     if (options.grep)
     105            4 :         cmd.push("--grep=" + options.grep);
     106              : 
     107              :     /* journalctl doesn't allow reverse and follow together */
     108           34 :     if (options.reverse)
     109           11 :         cmd.push("--reverse");
     110           33 :     else if (options.follow)
     111           33 :         cmd.push("--follow");
     112              : 
     113           34 :     cmd.push("--");
     114           34 :     cmd.push.apply(cmd, matches);
     115           34 :     return cmd;
     116           34 : };
     117              : 
     118           34 : journal.journalctl = function journalctl(/* ... */) {
     119           34 :     const cmd = journal.build_cmd.apply(null, arguments);
     120              : 
     121           34 :     const dfd = cockpit.defer();
     122           34 :     const promise = dfd.promise();
     123           34 :     let buffer = "";
     124           34 :     let entries = [];
     125           34 :     let streamers = [];
     126           34 :     let interval = null;
     127              : 
     128           28 :     function fire_streamers() {
     129           28 :         let ents;
     130           28 :         let i;
     131           24 :         if (streamers.length && entries.length > 0) {
     132           24 :             ents = entries;
     133           24 :             entries = [];
     134           24 :             for (i = 0; i < streamers.length; i++)
     135           24 :                 streamers[i].apply(promise, [ents]);
     136           24 :         } else {
     137           28 :             window.clearInterval(interval);
     138           28 :             interval = null;
     139           28 :         }
     140           28 :     }
     141              : 
     142           34 :     const proc = cockpit.spawn(cmd, { batch: 8192, latency: 300, superuser: "try" })
     143           29 :             .stream(function(data) {
     144           29 :                 if (buffer)
     145           13 :                     data = buffer + data;
     146           29 :                 buffer = "";
     147              : 
     148           29 :                 const lines = data.split("\n");
     149           29 :                 const last = lines.length - 1;
     150           29 :                 lines.forEach(function(line, i) {
     151           29 :                     if (i == last) {
     152           29 :                         buffer = line;
     153           29 :                     } else if (line && line.indexOf("-- ") !== 0) {
     154           29 :                         try {
     155           29 :                             entries.push(JSON.parse(line));
     156            2 :                         } catch (e) {
     157            2 :                             console.warn(e, line);
     158            2 :                         }
     159           29 :                     }
     160           29 :                 });
     161              : 
     162           29 :                 if (streamers.length && interval === null)
     163           29 :                     interval = window.setInterval(fire_streamers, 300);
     164           29 :             })
     165           10 :             .done(function() {
     166           10 :                 fire_streamers();
     167           10 :                 dfd.resolve(entries);
     168           10 :             })
     169           20 :             .fail(function(ex) {
     170              :             /* The journalctl command fails when no entries are matched
     171              :              * so we just ignore this status code */
     172           20 :                 if (ex.problem == "cancelled" ||
     173            2 :                 ex.exit_status === 1) {
     174           20 :                     fire_streamers();
     175           20 :                     dfd.resolve(entries);
     176            1 :                 } else {
     177            1 :                     dfd.reject(ex);
     178            1 :                 }
     179           20 :             })
     180           21 :             .always(function() {
     181           21 :                 window.clearInterval(interval);
     182           21 :             });
     183              : 
     184           34 :     promise.stream = function stream(callback) {
     185           34 :         streamers.push(callback);
     186           34 :         return this;
     187           34 :     };
     188           20 :     promise.stop = function stop() {
     189           20 :         streamers = [];
     190           20 :         promise.stopped = true;
     191           20 :         proc.close("cancelled");
     192           20 :     };
     193           34 :     return promise;
     194           34 : };
     195              : 
     196           24 : journal.printable = function printable(value, key) {
     197           24 :     if (value === undefined || value === null)
     198            2 :         return _("[no data]");
     199           24 :     else if (typeof (value) == "string")
     200            2 :         return value;
     201            2 :     else if (value.length !== undefined && value.length <= 1000 && key == "MESSAGE")
     202            2 :         return new TextDecoder().decode(new Uint8Array(value));
     203            2 :     else {
     204            2 :         return _("[binary data]");
     205            2 :     }
     206           24 : };
     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           34 : journal.renderer = function renderer(output_funcs) {
     271           34 :     if (!output_funcs.render_line)
     272            3 :         console.error("Invalid renderer provided");
     273              : 
     274           16 :     function copy_object(o) {
     275           16 :         const c = { }; for (const p in o) c[p] = o[p]; return c;
     276           16 :     }
     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           24 :     function format_entry(journal_entry) {
     283           24 :         const d = journal_entry.__REALTIME_TIMESTAMP / 1000; // timestamps are in µs
     284           24 :         return {
     285           24 :             cursor: journal_entry.__CURSOR,
     286           24 :             full: journal_entry,
     287           24 :             day: timeformat.date(d),
     288           24 :             time: timeformat.time(d),
     289           24 :             bootid: journal_entry._BOOT_ID,
     290            1 :             ident: journal_entry.SYSLOG_IDENTIFIER || journal_entry._COMM,
     291           24 :             prio: journal_entry.PRIORITY,
     292           24 :             message: journal.printable(journal_entry.MESSAGE, "MESSAGE")
     293           24 :         };
     294           24 :     }
     295              : 
     296           24 :     function entry_is_equal(a, b) {
     297           16 :         return (a && b &&
     298           16 :                 a.day == b.day &&
     299           16 :                 a.bootid == b.bootid &&
     300           16 :                 a.ident == b.ident &&
     301           15 :                 a.prio == b.prio &&
     302           13 :                 a.message == b.message);
     303           24 :     }
     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           24 :     function render_state_line(state) {
     321           24 :         return output_funcs.render_line(state.entry.ident,
     322           24 :                                         state.entry.prio,
     323           24 :                                         state.entry.message,
     324           24 :                                         state.count,
     325           24 :                                         state.last_time,
     326           24 :                                         state.entry.full);
     327           24 :     }
     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           34 :     let top_state;
     338           34 :     let bottom_state;
     339              : 
     340           34 :     top_state = bottom_state = { };
     341              : 
     342           24 :     function start_new_line() {
     343              :         // If we now have two lines, split the state
     344           16 :         if (top_state === bottom_state && top_state.entry) {
     345           16 :             top_state = copy_object(bottom_state);
     346           16 :         }
     347           24 :     }
     348              : 
     349           23 :     function top_output() {
     350           14 :         if (top_state.header_present) {
     351           14 :             output_funcs.remove_first();
     352           14 :             top_state.header_present = false;
     353           14 :         }
     354           14 :         if (top_state.line_present) {
     355           14 :             output_funcs.remove_first();
     356           14 :             top_state.line_present = false;
     357           14 :         }
     358           23 :         if (top_state.entry) {
     359           23 :             output_funcs.prepend(render_state_line(top_state));
     360           23 :             top_state.line_present = true;
     361           23 :         }
     362           23 :     }
     363              : 
     364           17 :     function prepend(journal_entry) {
     365           17 :         const entry = format_entry(journal_entry);
     366              : 
     367            1 :         if (entry_is_equal(top_state.entry, entry)) {
     368            1 :             top_state.count += 1;
     369            1 :             top_state.first_time = entry.time;
     370            1 :         } else {
     371           17 :             top_output();
     372              : 
     373            9 :             if (top_state.entry) {
     374            9 :                 if (entry.bootid != top_state.entry.bootid)
     375            0 :                     output_funcs.prepend(output_funcs.render_reboot_separator());
     376            9 :                 if (entry.day != top_state.entry.day)
     377            0 :                     output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
     378            9 :             }
     379              : 
     380           17 :             start_new_line();
     381           17 :             top_state.entry = entry;
     382           17 :             top_state.count = 1;
     383           17 :             top_state.first_time = top_state.last_time = entry.time;
     384           17 :             top_state.line_present = false;
     385           17 :         }
     386           17 :     }
     387              : 
     388           23 :     function prepend_flush() {
     389           23 :         top_output();
     390           23 :         if (top_state.entry) {
     391           23 :             output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
     392           23 :             top_state.header_present = true;
     393           23 :         }
     394           23 :     }
     395              : 
     396            9 :     function bottom_output() {
     397            5 :         if (bottom_state.line_present) {
     398            5 :             output_funcs.remove_last();
     399            5 :             bottom_state.line_present = false;
     400            5 :         }
     401            9 :         if (bottom_state.entry) {
     402            9 :             output_funcs.append(render_state_line(bottom_state));
     403            9 :             bottom_state.line_present = true;
     404            9 :         }
     405            9 :     }
     406              : 
     407            9 :     function append(journal_entry) {
     408            9 :         const entry = format_entry(journal_entry);
     409              : 
     410            4 :         if (entry_is_equal(bottom_state.entry, entry)) {
     411            4 :             bottom_state.count += 1;
     412            4 :             bottom_state.last_time = entry.time;
     413            4 :         } else {
     414            9 :             bottom_output();
     415              : 
     416            8 :             if (!bottom_state.entry || entry.day != bottom_state.entry.day) {
     417            9 :                 output_funcs.append(output_funcs.render_day_header(entry.day));
     418            9 :                 bottom_state.header_present = true;
     419            9 :             }
     420            8 :             if (bottom_state.entry && entry.bootid != bottom_state.entry.bootid)
     421            5 :                 output_funcs.append(output_funcs.render_reboot_separator());
     422              : 
     423            9 :             start_new_line();
     424            9 :             bottom_state.entry = entry;
     425            9 :             bottom_state.count = 1;
     426            9 :             bottom_state.first_time = bottom_state.last_time = entry.time;
     427            9 :             bottom_state.line_present = false;
     428            9 :         }
     429            9 :     }
     430              : 
     431            9 :     function append_flush() {
     432            9 :         bottom_output();
     433            9 :     }
     434              : 
     435           34 :     return {
     436           34 :         prepend,
     437           34 :         prepend_flush,
     438           34 :         append,
     439           34 :         append_flush
     440           34 :     };
     441           34 : };
        

Generated by: LCOV version 2.0-1