/* * This file is part of Cockpit. * * Copyright (C) 2017 Red Hat, Inc. * * Cockpit is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation; either version 2.1 of the License, or * (at your option) any later version. * * Cockpit is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ import React from 'react'; import './player.css'; import { Terminal as Term } from 'xterm'; import { Alert, AlertGroup, Button, Chip, ChipGroup, DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow, ExpandableSection, InputGroup, Progress, TextInput, Toolbar, ToolbarContent, ToolbarItem, ToolbarGroup, } from '@patternfly/react-core'; import { ArrowRightIcon, ExpandIcon, PauseIcon, PlayIcon, RedoIcon, SearchMinusIcon, SearchPlusIcon, SearchIcon, MinusIcon, UndoIcon, ThumbtackIcon, MigrationIcon, } from '@patternfly/react-icons'; import cockpit from 'cockpit'; import { journal } from 'journal'; const _ = cockpit.gettext; const $ = require("jquery"); const padInt = function (n, w) { const i = Math.floor(n); const a = Math.abs(i); let s = a.toString(); for (w -= s.length; w > 0; w--) { s = '0' + s; } return ((i < 0) ? '-' : '') + s; }; /* * Format date and time for a number of milliseconds since Epoch. * YYYY-MM-DD HH:mm:ss */ const formatDateTime = function (ms) { /* Convert local timezone offset */ const t = new Date(ms); const z = t.getTimezoneOffset() * 60 * 1000; let tLocal = t - z; tLocal = new Date(tLocal); let iso = tLocal.toISOString(); /* cleanup ISO format */ iso = iso.slice(0, 19); iso = iso.replace('T', ' '); return iso; }; /* * Format a time interval from a number of milliseconds. */ const formatDuration = function (ms) { let v = Math.floor(ms / 1000); const s = Math.floor(v % 60); v = Math.floor(v / 60); const m = Math.floor(v % 60); v = Math.floor(v / 60); const h = Math.floor(v % 24); const d = Math.floor(v / 24); let str = ''; if (d > 0) { str += d + ' ' + _("days") + ' '; } if (h > 0 || str.length > 0) { str += padInt(h, 2) + ':'; } str += padInt(m, 2) + ':' + padInt(s, 2); return (ms < 0 ? '-' : '') + str; }; const scrollToBottom = function(id) { const el = document.getElementById(id); if (el) { el.scrollTop = el.scrollHeight; } }; function ErrorList(props) { let list = []; if (props.list) { list = props.list.map((message, key) => { return }); } return ( {list} ); } function ErrorItem(props) { return ( {props.message} ); } const ErrorService = class { constructor() { this.addMessage = this.addMessage.bind(this); this.errors = []; } addMessage(message) { if (typeof message === "object" && message !== null) { if ("toString" in message) { message = message.toString(); } else { message = _("unknown error"); } } if (typeof message === "string" || message instanceof String) { if (this.errors.indexOf(message) === -1) { this.errors.push(message); } } } }; /* * An auto-loading buffer of recording's packets. */ const PacketBuffer = class { /* * Initialize a buffer. */ constructor(matchList, reportError) { this.handleError = this.handleError.bind(this); this.handleStream = this.handleStream.bind(this); this.handleDone = this.handleDone.bind(this); this.getValidField = this.getValidField.bind(this); /* RegExp used to parse message's timing field */ this.timingRE = new RegExp( /* Delay (1) */ "\\+(\\d+)|" + /* Text input (2) */ "<(\\d+)|" + /* Binary input (3, 4) */ "\\[(\\d+)/(\\d+)|" + /* Text output (5) */ ">(\\d+)|" + /* Binary output (6, 7) */ "\\](\\d+)/(\\d+)|" + /* Window (8, 9) */ "=(\\d+)x(\\d+)|" + /* End of string */ "$", /* Continue after the last match only */ /* FIXME Support likely sparse */ "y" ); /* List of matches to apply when loading the buffer from Journal */ this.matchList = matchList; this.reportError = reportError; /* * An array of two-element arrays (tuples) each containing a * packet index and a deferred object. The list is kept sorted to * have tuples with lower packet indices first. Once the buffer * receives a packet at the specified index, the matching tuple is * removed from the list, and its deferred object is resolved. * This is used to keep users informed about packets arriving. */ this.idxDfdList = []; /* Last seen message ID */ this.id = 0; /* Last seen time position */ this.pos = 0; /* Last seen window width */ this.width = null; /* Last seen window height */ this.height = null; /* List of packets read */ this.pktList = []; /* Error which stopped the loading */ this.error = null; /* The journalctl reading the recording */ this.journalctl = journal.journalctl( this.matchList, { count: "all", follow: false, merge: true }); this.journalctl.fail(this.handleError); this.journalctl.stream(this.handleStream); this.journalctl.done(this.handleDone); /* * Last seen cursor of the first, non-follow, journalctl run. * Null if no entry was received yet, or the second run has * skipped the entry received last by the first run. */ this.cursor = null; /* True if the first, non-follow, journalctl run has completed */ this.done = false; } /* * Get an object field, verifying its presence and type. */ getValidField(object, field, type) { if (!(field in object)) { this.reportError("\"" + field + "\" field is missing"); } const value = object[field]; if (typeof (value) != typeof (type)) { this.reportError("invalid \"" + field + "\" field type: " + typeof (value)); } return value; } /* * Return a promise which is resolved when a packet at a particular * index is received by the buffer. The promise is rejected with a * non-null argument if an error occurs or has occurred previously. * The promise is rejected with null, when the buffer is stopped. If * the packet index is not specified, assume it's the next packet. */ awaitPacket(idx) { let i; let idxDfd; /* If an error has occurred previously */ if (this.error !== null) { /* Reject immediately */ return $.Deferred().reject(this.error) .promise(); } /* If the buffer was stopped */ if (this.journalctl === null) { return $.Deferred().reject(null) .promise(); } /* If packet index is not specified */ if (idx === undefined) { /* Assume it's the next one */ idx = this.pktList.length; } else { /* If it has already been received */ if (idx < this.pktList.length) { /* Return resolved promise */ return $.Deferred().resolve() .promise(); } } /* Try to find an existing, matching tuple */ for (i = 0; i < this.idxDfdList.length; i++) { idxDfd = this.idxDfdList[i]; if (idxDfd[0] === idx) { return idxDfd[1].promise(); } else if (idxDfd[0] > idx) { break; } } /* Not found, create and insert a new tuple */ idxDfd = [idx, $.Deferred()]; this.idxDfdList.splice(i, 0, idxDfd); /* Return its promise */ return idxDfd[1].promise(); } /* * Return true if the buffer was done loading everything logged to * journal so far and is now waiting for and loading new entries. * Return false if the buffer is loading existing entries so far. */ isDone() { return this.done; } /* * Stop receiving the entries */ stop() { if (this.journalctl === null) { return; } /* Destroy journalctl */ this.journalctl.stop(); this.journalctl = null; /* Notify everyone we stopped */ for (let i = 0; i < this.idxDfdList.length; i++) { this.idxDfdList[i][1].reject(null); } this.idxDfdList = []; } /* * Add a packet to the received packet list. */ addPacket(pkt) { /* TODO Validate the packet */ /* Add the packet */ this.pktList.push(pkt); /* Notify any matching listeners */ while (this.idxDfdList.length > 0) { const idxDfd = this.idxDfdList[0]; if (idxDfd[0] < this.pktList.length) { this.idxDfdList.shift(); idxDfd[1].resolve(); } else { break; } } } /* * Handle an error. */ handleError(error) { /* Remember the error */ this.error = error; /* Destroy journalctl, don't try to recover */ if (this.journalctl !== null) { this.journalctl.stop(); this.journalctl = null; } /* Notify everyone we had an error */ for (let i = 0; i < this.idxDfdList.length; i++) { this.idxDfdList[i][1].reject(error); } this.idxDfdList = []; this.reportError(error); } /* * Parse packets out of a tlog message data and add them to the buffer. */ parseMessageData(timing, in_txt, out_txt) { let matches; let in_txt_pos = 0; let out_txt_pos = 0; let t; let x; let y; let s; let io = []; let is_output; /* While matching entries in timing */ this.timingRE.lastIndex = 0; for (;;) { /* Match next timing entry */ matches = this.timingRE.exec(timing); if (matches === null) { this.reportError(_("invalid timing string")); } else if (matches[0] === "") { break; } /* Switch on entry type character */ switch (t = matches[0][0]) { /* Delay */ case "+": x = parseInt(matches[1], 10); if (x === 0) { break; } if (io.length > 0) { this.addPacket({ pos: this.pos, is_io: true, is_output, io: io.join() }); io = []; } this.pos += x; break; /* Text or binary input */ case "<": case "[": x = parseInt(matches[(t === "<") ? 2 : 3], 10); if (x === 0) { break; } if (io.length > 0 && is_output) { this.addPacket({ pos: this.pos, is_io: true, is_output, io: io.join() }); io = []; } is_output = false; /* Add (replacement) input characters */ s = in_txt.slice(in_txt_pos, in_txt_pos += x); if (s.length !== x) { this.reportError(_("timing entry out of input bounds")); } io.push(s); break; /* Text or binary output */ case ">": case "]": x = parseInt(matches[(t === ">") ? 5 : 6], 10); if (x === 0) { break; } if (io.length > 0 && !is_output) { this.addPacket({ pos: this.pos, is_io: true, is_output, io: io.join() }); io = []; } is_output = true; /* Add (replacement) output characters */ s = out_txt.slice(out_txt_pos, out_txt_pos += x); if (s.length !== x) { this.reportError(_("timing entry out of output bounds")); } io.push(s); break; /* Window */ case "=": x = parseInt(matches[8], 10); y = parseInt(matches[9], 10); if (x === this.width && y === this.height) { break; } if (io.length > 0) { this.addPacket({ pos: this.pos, is_io: true, is_output, io: io.join() }); io = []; } this.addPacket({ pos: this.pos, is_io: false, width: x, height: y }); this.width = x; this.height = y; break; default: // continue break; } } if (in_txt_pos < [...in_txt].length) { this.reportError(_("extra input present")); } if (out_txt_pos < [...out_txt].length) { this.reportError(_("extra output present")); } if (io.length > 0) { this.addPacket({ pos: this.pos, is_io: true, is_output, io: io.join() }); } } /* * Parse packets out of a tlog message and add them to the buffer. */ parseMessage(message) { const number = Number(); const string = String(); /* Check version */ const ver = this.getValidField(message, "ver", string); const matches = ver.match("^(\\d+)\\.(\\d+)$"); if (matches === null || matches[1] > 2) { this.reportError("\"ver\" field has invalid value: " + ver); } /* TODO Perhaps check host, rec, user, term, and session fields */ /* Extract message ID */ const id = this.getValidField(message, "id", number); if (id <= this.id) { this.reportError("out of order \"id\" field value: " + id); } /* Extract message time position */ const pos = this.getValidField(message, "pos", number); if (pos < this.message_pos) { this.reportError("out of order \"pos\" field value: " + pos); } /* Update last received message ID and time position */ this.id = id; this.pos = pos; /* Parse message data */ this.parseMessageData( this.getValidField(message, "timing", string), this.getValidField(message, "in_txt", string), this.getValidField(message, "out_txt", string)); } /* * Handle journalctl "stream" event. */ handleStream(entryList) { let i; let e; for (i = 0; i < entryList.length; i++) { e = entryList[i]; /* If this is the second, "follow", run */ if (this.done) { /* Skip the last entry we added on the first run */ if (this.cursor !== null) { this.cursor = null; continue; } } else { if (!('__CURSOR' in e)) { this.handleError("No cursor in a Journal entry"); } this.cursor = e.__CURSOR; } /* TODO Refer to entry number/cursor in errors */ if (!('MESSAGE' in e)) { this.handleError("No message in Journal entry"); } /* Parse the entry message */ try { const utf8decoder = new TextDecoder(); /* Journalctl stores fields with non-printable characters * in an array of raw bytes formatted as unsigned * integers */ if (Array.isArray(e.MESSAGE)) { const u8arr = new Uint8Array(e.MESSAGE); this.parseMessage(JSON.parse(utf8decoder.decode(u8arr))); } else { this.parseMessage(JSON.parse(e.MESSAGE)); } } catch (error) { this.handleError(error); return; } } } /* * Handle journalctl "done" event. */ handleDone() { this.done = true; if (this.journalctl !== null) { this.journalctl.stop(); this.journalctl = null; } /* Continue with the "following" run */ this.journalctl = journal.journalctl( this.matchList, { cursor: this.cursor, follow: true, merge: true, count: "all" }); this.journalctl.fail(this.handleError); this.journalctl.stream(this.handleStream); /* NOTE: no "done" handler on purpose */ } }; function SearchEntry(props) { return ( props.fastForwardToTS(props.pos, e)}>{formatDuration(props.pos)} ); } class Search extends React.Component { constructor(props) { super(props); this.handleInputChange = this.handleInputChange.bind(this); this.handleStream = this.handleStream.bind(this); this.handleError = this.handleError.bind(this); this.handleSearchSubmit = this.handleSearchSubmit.bind(this); this.handleClearSearchResults = this.handleClearSearchResults.bind(this); this.state = { search: cockpit.location.options.search_rec || cockpit.location.options.search || "", }; } handleInputChange(name, value) { const state = {}; state[name] = value; this.setState(state); cockpit.location.go(cockpit.location.path[0], $.extend(cockpit.location.options, { search_rec: value })); } handleSearchSubmit() { this.journalctl = journal.journalctl( this.props.matchList, { count: "all", follow: false, merge: true, grep: this.state.search }); this.journalctl.fail(this.handleError); this.journalctl.stream(this.handleStream); } handleStream(data) { let items = data.map(item => { return JSON.parse(item.MESSAGE); }); items = items.map(item => { return ( ); }); this.setState({ items }); } handleError(data) { this.props.errorService.addMessage(data); } handleClearSearchResults() { delete cockpit.location.options.search; cockpit.location.go(cockpit.location.path[0], cockpit.location.options); this.setState({ search: "" }); this.handleStream([]); } componentDidMount() { if (this.state.search) { this.handleSearchSubmit(); } } render() { return ( this.handleInputChange("search", value)} /> {this.state.items} ); } } class InputPlayer extends React.Component { render() { const input = String(this.props.input).replace(/(?:\r\n|\r|\n)/g, " "); return (