From f0daf245aa8b8be222146a4e6c538402c8c27bdf Mon Sep 17 00:00:00 2001 From: Nikolai Kondrashov Date: Thu, 1 Jun 2017 19:05:24 +0300 Subject: [PATCH 01/30] Add a hacked-together POC session recording page --- Makefile.am | 1 + pkg/session_recording/index.html | 37 +++ pkg/session_recording/manifest.json.in | 15 + pkg/session_recording/recordings.css | 308 +++++++++++++++++++ pkg/session_recording/recordings.jsx | 392 +++++++++++++++++++++++++ pkg/session_recording/timer.css | 163 ++++++++++ webpack.config.js | 8 + 7 files changed, 924 insertions(+) create mode 100644 pkg/session_recording/index.html create mode 100644 pkg/session_recording/manifest.json.in create mode 100644 pkg/session_recording/recordings.css create mode 100644 pkg/session_recording/recordings.jsx create mode 100644 pkg/session_recording/timer.css diff --git a/Makefile.am b/Makefile.am index 842cf5c769b1..62d42cb80128 100644 --- a/Makefile.am +++ b/Makefile.am @@ -156,6 +156,7 @@ WEBPACK_PACKAGES = \ playground \ realmd \ selinux \ + session_recording \ shell \ sosreport \ ssh \ diff --git a/pkg/session_recording/index.html b/pkg/session_recording/index.html new file mode 100644 index 000000000000..e23d550aa5ea --- /dev/null +++ b/pkg/session_recording/index.html @@ -0,0 +1,37 @@ + + + + + + Journal + + + + + + + + + +
+ + + + diff --git a/pkg/session_recording/manifest.json.in b/pkg/session_recording/manifest.json.in new file mode 100644 index 000000000000..bab1160e01fb --- /dev/null +++ b/pkg/session_recording/manifest.json.in @@ -0,0 +1,15 @@ +{ + "version": "@VERSION@", + "name": "session_recording", + + "requires": { + "cockpit": "122" + }, + + "menu": { + "index": { + "label": "Session Recording", + "order": 100 + } + } +} diff --git a/pkg/session_recording/recordings.css b/pkg/session_recording/recordings.css new file mode 100644 index 000000000000..b326f39c377f --- /dev/null +++ b/pkg/session_recording/recordings.css @@ -0,0 +1,308 @@ +@import "/page.css"; + +@import "/console.css"; +@import "/journal.css"; +@import "/plot.css"; +@import "/table.css"; + +@import "./timer.css"; + +a.disabled { + text-decoration: none; + pointer-events: none; + cursor: default; + color: #000; +} + +.popover { + max-width: none; + white-space: nowrap; +} + +.systime-inline form .pficon-close, +.systime-inline form .fa-plus { + height: 26px; + width: 26px; + padding: 4px; + float: right; + margin-left: 5px; +} + +.systime-inline .form-inline { + background: #f4f4f4; + border-width: 0 1px 1px 1px; + border-style: solid; + border-color: #bababa; + padding: 4px; +} + +.systime-inline .form-inline:first-of-type { + border-top: 1px solid #bababa; +} + +.systime-inline .form-control { + margin: 0 4px; +} + +.systime-inline .form-group:first-of-type .form-control { + margin: 0 4px 0 0; +} + +.systime-inline .form-group .form-control { + width: 214px; +} + +/* Make sure error message don't overflow the dialog */ + +.realms-op-diagnostics { + max-width: 550px; + text-align: left; + max-height: 200px; +} + +.realms-op-wait-message { + margin-left: 10px; + float: left; + margin-top: 3px; +} + +.realms-op-address-spinner { + margin-left: -30px; + margin-top: 2px; +} + +.realms-op-address-error { + margin-left: -30px; + margin-top: 2px; + color: red; +} + +.realms-op-zero-width { + width: 0px; +} + +.realms-op-error { + text-align: left; + font-weight: bold; + overflow: auto; + max-width: 550px; + max-height: 200px; +} + +/* Other styles */ + +.fa-red { + color: red; +} + +.small-messages { + font-size: smaller; +} + +#server-graph-toolbar .dropdown { + display: inline-block; +} + +#server-graph-toolbar .dropdown-toggle span { + width: 6em; + text-align: left; + padding-left: 5px; + display: inline-block; +} + +.server-graph { + height: 120px; +} + +.server-graph-legend { + list-style-type: none; + padding: 30px 40px; + float: right; +} + +.server-graph-legend i { + padding-right: 3px; + font-size: 14px; +} + +.server-graph-legend .cpu-io-wait i { + color: #e41a1c; +} + +.server-graph-legend .cpu-kernel i { + color: #ff7f00; +} + +.server-graph-legend .cpu-user i { + color: #377eb8; +} + +.server-graph-legend .cpu-nice i { + color: #4daf4a; +} + +.server-graph-legend .memory-swap i { + color: #e41a1c; +} + +.server-graph-legend .memory-cached i { + color: #ff7f00; +} + +.server-graph-legend .memory-used i { + color: #377eb8; +} + +.server-graph-legend .memory-free i { + color: #4daf4a; +} + +#cpu_status_graph, +#memory_status_graph { + height: 400px; + padding: 20px; +} + +#sich-note-1, +#sich-note-2 { + margin: 0; +} +#shutdown-dialog td { + padding-right: 20px; + vertical-align: top; +} + +#shutdown-dialog .opt { + padding: 1px 10px; +} + +#shutdown-dialog .dropdown { + min-width: 150px; +} + +#shutdown-group { + overflow: visible; +} + +#shutdown-dialog textarea { + resize: none; + margin-bottom: 10px; +} + +#shutdown-dialog input { + display: inline; + width: 10em; +} + +#shutdown-dialog .shutdown-hours, +#shutdown-dialog .shutdown-minutes { + width: 3em; +} + +#system_information_ssh_keys .list-group-item { + cursor: auto; +} + +#system_information_hardware_text, +#system_information_os_text { + overflow: visible; + white-space: normal; + word-wrap: break-word; +} + +@media (min-width: 500px) { + .cockpit-modal-md { + width: 400px; + } +} + +/* Make sure to not break log message lines in order to preserve information */ +#journal-entry .info-table-ct td { + white-space: normal; + word-break: break-all; +} + +.service-unit-description { + font-weight: bold; +} + +.service-unit-data { + text-align: right; + white-space: nowrap; +} + +.service-unit-failed { + color: red; +} + +.service-action-btn ul { + right: 0px; + left: auto; + min-width: 0; + text-align: left; +} + +.service-panel td:first-child { + text-align: left; +} + +.service-panel span { + font-weight: bold; +} + +.service-panel td:last-child { + text-align: right; +} + +.service-panel table { + width: 100%; +} + +#services > .container-fluid { + margin-top: 5em; +} + +.service-template input { + width: 50em; +} + +#journal-current-day-menu dropdown-toggle { + padding-left: 10px; +} + +#journal-box { + margin-top: 5em; +} + +#journal-entry-message { + margin: 10px; +} + +#journal-entry-fields { + margin-bottom: 10px; +} + +/* Extra content header */ + +.content-header-extra { + background: #f5f5f5; + border-bottom: 1px solid #ddd; + padding: 10px 20px; + width: 100%; + position: fixed; + z-index: 900; + top: 0; +} + +.content-header-extra .btn-group:not(:first-child) { + padding-left: 20px; +} + +#motd { + background-color: transparent; + border: none; + font-size: 14px; + padding: 0px; + margin: 0px; + white-space: pre-wrap; +} diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx new file mode 100644 index 000000000000..fa5a0608d0c2 --- /dev/null +++ b/pkg/session_recording/recordings.jsx @@ -0,0 +1,392 @@ +/* + * 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 . + */ + +(function() { + "use strict"; + + var cockpit = require("cockpit"); + var _ = cockpit.gettext; + var Journal = require("journal"); + var React = require("react"); + var Listing = require("cockpit-components-listing.jsx"); + var Terminal = require("cockpit-components-terminal.jsx"); + + /* + * A component representing a single recording view. + * Properties: + * - recording: either null for no recording data available yet, or a + * recording object, as created by the View below. + */ + var Recording = class extends React.Component { + constructor(props) { + super(props); + this.state = { + channel: null, + }; + } + + /* + * Create a cockpit channel to a tlog-play instance playing the + * specified recording. Returns null if there is no recording data. + */ + createChannel() { + var r = this.props.recording; + if (!r) { + return null; + } + return cockpit.channel({ + "payload": "stream", + "spawn": [ + "/usr/bin/tlog-play", + "--follow", + "--reader=journal", + "-M", "_BOOT_ID=" + r.boot_id, + "-M", "TLOG_SESSION=" + r.session_id, + "-M", "_PID=" + r.pid, + ], + "environ": [ + "TERM=xterm-256color", + "PATH=/sbin:/bin:/usr/sbin:/usr/bin" + ], + "directory": "/", + "pty": true + }); + } + + componentDidMount() { + this.setState({channel: this.createChannel()}); + } + + componentDidUpdate(prevProps) { + if (this.props.recording != prevProps.recording) { + var channel; + if (this.state.channel != null) { + this.state.channel.close(); + } + if (this.props.recording == null) { + channel = null; + } else { + channel = this.createChannel(); + } + this.setState({channel: channel}); + } + } + + componentWillUnmount() { + if (this.state.channel != null) { + this.state.channel.close(); + } + } + + render() { + var r = this.props.recording; + if (r == null) { + return Loading...; + } else { + var terminal; + + if (this.state.channel) { + terminal = (); + } else { + terminal = Loading...; + } + + return ( +
+ {_("Recording")} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{_("ID")}{r.id}
{_("Boot ID")}{r.boot_id}
{_("Session ID")}{r.session_id}
{_("PID")}{r.pid}
{_("Start")}{(new Date(r.start)).toString()}
{_("End")}{(new Date(r.end)).toString()}
{_("Duration")}XX:XX
{_("User")}{r.user}
{_("Playback")}{terminal}
+
+ ); + } + } + }; + + /* + * A component representing a list of recordings. + * Properties: + * - list: an array with recording objects, as created by the View below + */ + var RecordingList = class extends React.Component { + constructor(props) { + super(props); + } + + /* + * Set the cockpit location to point to the specified recording. + */ + navigateToRecording(recording) { + cockpit.location.go([recording.id]); + } + + render() { + var columnTitles = [_("ID"), _("Start"), _("End"), _("Duration"), _("User")]; + var list = this.props.list; + var rows = []; + for (var i = 0; i < list.length; i++) { + var r = list[i]; + var columns = [r.id, + (new Date(r.start)).toString(), + (new Date(r.end)).toString(), + "XX:XX", + r.user]; + rows.push(); + } + return ( + + {rows} + + ); + } + }; + + /* + * A component representing the view upon a list of recordings, or a + * single recording. Extracts the ID of the recording to display from + * cockpit.location.path[0]. If it's zero, displays the list. + */ + var View = class extends React.Component { + constructor(props) { + super(props); + this.onLocationChanged = this.onLocationChanged.bind(this); + this.journalctlIngest = this.journalctlIngest.bind(this); + /* Journalctl instance */ + this.journalctl = null; + /* Recording ID journalctl instance is invoked with */ + this.journalctlRecordingID = null; + /* Recording ID -> data map */ + this.recordingMap = {}; + this.state = { + /* List of recordings in start order */ + recordingList: [], + /* ID of the recording to display, or null for all */ + recordingID: cockpit.location.path[0] || null, + } + } + + /* + * Display a journalctl error + */ + journalctlError(error) { + console.warn(cockpit.message(error)); + } + + /* + * Respond to cockpit location change by extracting and setting the + * displayed recording ID. + */ + onLocationChanged() { + this.setState({recordingID: cockpit.location.path[0] || null}); + } + + /* + * Ingest journal entries sent by journalctl. + */ + journalctlIngest(entryList) { + var recordingList = this.state.recordingList.slice(); + var i; + var j; + + for (i = 0; i < entryList.length; i++) { + var e = entryList[i]; + var boot_id = e["_BOOT_ID"]; + var session_id = e["TLOG_SESSION"]; + var process_id = e["_PID"]; + + /* Skip entries with missing session ID */ + if (session_id === undefined) { + continue; + } + + var id = boot_id + "-" + session_id + "-" + process_id; + var ts = Math.floor( + parseInt(e["__REALTIME_TIMESTAMP"], 10) / + 1000); + + var r = this.recordingMap[id]; + /* If no recording found */ + if (r === undefined) { + /* Create new recording */ + r = {id: id, + user: e["TLOG_USER"], + boot_id: e["_BOOT_ID"], + session_id: parseInt(e["TLOG_SESSION"], 10), + pid: parseInt(e["_PID"], 10), + start: ts, + /* FIXME Should be start + message duration */ + end: ts}; + /* Map the recording */ + this.recordingMap[id] = r; + /* Insert the recording in order */ + for (j = recordingList.length - 1; + j >= 0 && r.start < recordingList[j].start; + j--); + recordingList.splice(j + 1, 0, r); + } else { + /* Adjust existing recording */ + if (ts > r.end) { + r.end = ts; + } + if (ts < r.start) { + r.start = ts; + /* Find the recording in the list */ + for (j = recordingList.length - 1; + j >= 0 && recordingList[j] != r; + j--); + /* If found */ + if (j >= 0) { + /* Remove */ + recordingList.splice(j, 1); + } + /* Insert the recording in order */ + for (j = recordingList.length - 1; + j >= 0 && r.start < recordingList[j].start; + j--); + recordingList.splice(j + 1, 0, r); + } + } + } + + this.setState({recordingList: recordingList}); + } + + /* + * Start journalctl, retrieving entries for the current recording ID. + * Assumes journalctl is not running. + */ + journalctlStart() { + /* TODO Lookup UID of "tlog" user on module init */ + var matches = ["_UID=987"]; + var options = {follow: true, count: "all"}; + + if (this.state.recordingID !== null) { + var parts = this.state.recordingID.split('-', 3); + matches = matches.concat([ + "_BOOT_ID=" + parts[0], + "TLOG_SESSION=" + parts[1], + "_PID=" + parts[2] + ]); + } + + this.journalctlRecordingID = this.state.recordingID; + this.journalctl = Journal.journalctl(matches, options). + fail(this.journalctlError). + stream(this.journalctlIngest); + } + + /* + * Check if journalctl is running. + */ + journalctlIsRunning() { + return this.journalctl != null; + } + + /* + * Stop current journalctl. + * Assumes journalctl is running. + */ + journalctlStop() { + this.journalctl.stop(); + this.journalctl = null; + } + + componentDidMount() { + this.journalctlStart(); + cockpit.addEventListener("locationchanged", + this.onLocationChanged); + } + + componentWillUnmount() { + if (this.journalctlIsRunning()) { + this.journalctlStop(); + } + } + + componentDidUpdate(prevProps, prevState) { + /* + * If we're running a specific (non-wildcard) journalctl + * and recording ID has changed + */ + if (this.journalctlRecordingID !== null && + this.state.recordingID != prevState.recordingID) { + if (this.journalctlIsRunning()) { + this.journalctlStop(); + } + this.journalctlStart(); + } + } + + render() { + if (this.state.recordingID === null) { + return ; + } else { + return ( + + ); + } + } + }; + + React.render(, document.getElementById('view')); +}()); diff --git a/pkg/session_recording/timer.css b/pkg/session_recording/timer.css new file mode 100644 index 000000000000..d0deec439a93 --- /dev/null +++ b/pkg/session_recording/timer.css @@ -0,0 +1,163 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2015 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 . + */ +#create-timer { + display: none; +} +.vertical-scroll { + max-height: 150px; + overflow-y: scroll; +} + +.position-colon { + display: inline-block; +} + +div#boot { + display: inline-block; + float: right; +} + +div#boot-or-specific-time { + width: 170px; + display: inline-block; +} + +div#drop-time { + width: 100px; + display: inline-block; +} + +input#boot-time { + width: 50px; + display: inline-block; + position: relative; + top: 2px; +} + +.hr, .min { + width:30px; + display: inline-block; +} + +.form-inline { + background: #f4f4f4; + border-width: 0 1px 1px 1px; + border-style: solid; + border-color: #bababa; + padding: 4px; +} + +#boot-label { + position: relative; + right: 8px; + white-space: nowrap; + color: #888888; +} + +#repeat-time .form-inline:first-of-type { + border-top: 1px solid #bababa; +} + +#repeat-time [data-content="month-days"] { + width: 75px; +} + +#repeat-time [data-content="week-days"] { + width: 100px; +} + +#repeat-time [data-content="close"] { + position: relative; + float: right; + right: 8px; + top: 2px; +} + +#repeat-time [data-content="add"] { + position: relative; + float: right; + right: 4px; + top: 2px; +} + +#repeat-time [data-provide="datepicker"] { + width: 120px; +} + +[data-content='day-error'].repeat-error { + display: block; + font-size: 11px; + color: #4d5258; + line-height: 14px; +} + +.has-error { + border-color: #cc0000; +} + +.has-error:hover { + border-color: #990000; +} + +.has-error:focus { + border-color: #990000; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff3333; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff3333; +} + +.repeat-error { + display: block; + font-size: 11px; + color: #cc0000; + line-height: 14px; +} + +#services-page .datepicker-dropdown .prev, +#services-page .datepicker-dropdown .next { + display: none; + visibility: hidden; +} + +.date { + width:120px; +} + +#hr-error, #min-error { + font-size: 11px; + line-height: 13px; +} + +.help-block { + position: relative; + bottom: 6px; +} + +@media (min-width: 500px) { + .cockpit-timer-modal-md { + width: 500px; + } + .form-inline .form-control { + display: inline-block; + width: 30px; + vertical-align: middle; + } + .form-inline .date .bootstrap-datepicker { + width: 100px; + } +} diff --git a/webpack.config.js b/webpack.config.js index 57fa24043243..0ab4e994b082 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -123,6 +123,11 @@ var info = { "subscriptions/subscriptions.css", ], + "session_recording/recordings": [ + "session_recording/recordings.jsx", + "session_recording/recordings.css", + ], + "systemd/services": [ "systemd/init.js", ], @@ -259,6 +264,9 @@ var info = { "subscriptions/index.html", "subscriptions/manifest.json", + "session_recording/manifest.json", + "session_recording/index.html", + "systemd/index.html", "systemd/logs.html", "systemd/manifest.json", From f19b7ddd7366abed6edbc6cc404ad08bd47e4ba0 Mon Sep 17 00:00:00 2001 From: Nikolai Kondrashov Date: Thu, 6 Jul 2017 16:06:05 +0300 Subject: [PATCH 02/30] Refine session recording looks a bit --- pkg/session_recording/recordings.jsx | 87 ++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index fa5a0608d0c2..8cbef7d9b01b 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -27,6 +27,61 @@ var Listing = require("cockpit-components-listing.jsx"); var Terminal = require("cockpit-components-terminal.jsx"); + /* + * Convert a number to integer number string and pad with zeroes to + * specified width. + */ + var padInt = function (n, w) { + var i = Math.floor(n); + var a = Math.abs(i); + var s = a.toString(); + for (w -= s.length; w > 0; w--) { + s = '0' + s; + } + return ((a < 0) ? '-' : '') + s; + } + + /* + * Format date and time for a number of milliseconds since Epoch. + */ + var formatDateTime = function (ms) { + var d = new Date(ms); + return ( + padInt(d.getFullYear(), 4) + '-' + + padInt(d.getMonth(), 2) + '-' + + padInt(d.getDate(), 2) + ' ' + + padInt(d.getHours(), 2) + ':' + + padInt(d.getMinutes(), 2) + ':' + + padInt(d.getSeconds(), 2) + ); + }; + + /* + * Format a time interval from a number of milliseconds. + */ + var formatDuration = function (ms) { + var v = Math.floor(ms / 1000); + var s = Math.floor(v % 60); + v = Math.floor(v / 60); + var m = Math.floor(v % 60); + v = Math.floor(v / 60); + var h = Math.floor(v % 24); + var d = Math.floor(v / 24); + var 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; + }; + /* * A component representing a single recording view. * Properties: @@ -104,6 +159,7 @@ if (this.state.channel) { terminal = (); } else { terminal = Loading...; @@ -111,12 +167,8 @@ return (
- {_("Recording")} +

{_("Recording")}

- - - - @@ -131,25 +183,22 @@ - + - + - + - - - -
{_("ID")}{r.id}
{_("Boot ID")} {r.boot_id}
{_("Start")}{(new Date(r.start)).toString()}{formatDateTime(r.start)}
{_("End")}{(new Date(r.end)).toString()}{formatDateTime(r.end)}
{_("Duration")}XX:XX{formatDuration(r.end - r.start)}
{_("User")} {r.user}
{_("Playback")}{terminal}
+ {terminal}
); } @@ -174,16 +223,15 @@ } render() { - var columnTitles = [_("ID"), _("Start"), _("End"), _("Duration"), _("User")]; + var columnTitles = [_("User"), _("Start"), _("End"), _("Duration")]; var list = this.props.list; var rows = []; for (var i = 0; i < list.length; i++) { var r = list[i]; - var columns = [r.id, - (new Date(r.start)).toString(), - (new Date(r.end)).toString(), - "XX:XX", - r.user]; + var columns = [r.user, + formatDateTime(r.start), + formatDateTime(r.end), + formatDuration(r.end - r.start)]; rows.push( + emptyCaption={_("No recorded sessions")} + fullWidth={false}> {rows} ); From cd55ad375e1bad985c1efba1d81b0955bfdb901f Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Wed, 12 Jul 2017 10:18:41 +0200 Subject: [PATCH 03/30] Fix to month display There was a bug with month display, because JavaScript shows monthes from 0 to 11 from a Date object, so I converted it to human readable. --- pkg/session_recording/recordings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 8cbef7d9b01b..fa4beb68043a 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -48,7 +48,7 @@ var d = new Date(ms); return ( padInt(d.getFullYear(), 4) + '-' + - padInt(d.getMonth(), 2) + '-' + + padInt(d.getMonth() + 1, 2) + '-' + padInt(d.getDate(), 2) + ' ' + padInt(d.getHours(), 2) + ':' + padInt(d.getMinutes(), 2) + ':' + From a55f71138296545a07e5af0077b8c6e1a871b963 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 18 Jul 2017 15:11:07 +0200 Subject: [PATCH 04/30] Switch to let instead of var Because it's proper way to use let in ES6. It's more memory efficient and protects from possible conflicts with other scopes. --- pkg/session_recording/recordings.jsx | 90 ++++++++++++++-------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index fa4beb68043a..4f91e0019aaa 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -20,21 +20,21 @@ (function() { "use strict"; - var cockpit = require("cockpit"); - var _ = cockpit.gettext; - var Journal = require("journal"); - var React = require("react"); - var Listing = require("cockpit-components-listing.jsx"); - var Terminal = require("cockpit-components-terminal.jsx"); + let cockpit = require("cockpit"); + let _ = cockpit.gettext; + let Journal = require("journal"); + let React = require("react"); + let Listing = require("cockpit-components-listing.jsx"); + let Terminal = require("cockpit-components-terminal.jsx"); /* * Convert a number to integer number string and pad with zeroes to * specified width. */ - var padInt = function (n, w) { - var i = Math.floor(n); - var a = Math.abs(i); - var s = a.toString(); + let padInt = function (n, w) { + let i = Math.floor(n); + let a = Math.abs(i); + let s = a.toString(); for (w -= s.length; w > 0; w--) { s = '0' + s; } @@ -44,8 +44,8 @@ /* * Format date and time for a number of milliseconds since Epoch. */ - var formatDateTime = function (ms) { - var d = new Date(ms); + let formatDateTime = function (ms) { + let d = new Date(ms); return ( padInt(d.getFullYear(), 4) + '-' + padInt(d.getMonth() + 1, 2) + '-' + @@ -59,15 +59,15 @@ /* * Format a time interval from a number of milliseconds. */ - var formatDuration = function (ms) { - var v = Math.floor(ms / 1000); - var s = Math.floor(v % 60); + let formatDuration = function (ms) { + let v = Math.floor(ms / 1000); + let s = Math.floor(v % 60); v = Math.floor(v / 60); - var m = Math.floor(v % 60); + let m = Math.floor(v % 60); v = Math.floor(v / 60); - var h = Math.floor(v % 24); - var d = Math.floor(v / 24); - var str = ''; + let h = Math.floor(v % 24); + let d = Math.floor(v / 24); + let str = ''; if (d > 0) { str += d + ' ' + _("days") + ' '; @@ -88,7 +88,7 @@ * - recording: either null for no recording data available yet, or a * recording object, as created by the View below. */ - var Recording = class extends React.Component { + let Recording = class extends React.Component { constructor(props) { super(props); this.state = { @@ -101,7 +101,7 @@ * specified recording. Returns null if there is no recording data. */ createChannel() { - var r = this.props.recording; + let r = this.props.recording; if (!r) { return null; } @@ -130,7 +130,7 @@ componentDidUpdate(prevProps) { if (this.props.recording != prevProps.recording) { - var channel; + let channel; if (this.state.channel != null) { this.state.channel.close(); } @@ -150,11 +150,11 @@ } render() { - var r = this.props.recording; + let r = this.props.recording; if (r == null) { return Loading...; } else { - var terminal; + let terminal; if (this.state.channel) { terminal = ( { this.textInput = input; }} + className="form-control date" + type="text" + onChange={this.handleDateChange} /> + ); + } + } + /* * A component representing a single recording view. * Properties: @@ -213,6 +257,12 @@ let RecordingList = class extends React.Component { constructor(props) { super(props); + this.handleDateSinceChange = this.handleDateSinceChange.bind(this); + this.handleDateUntilChange = this.handleDateUntilChange.bind(this); + this.state = { + dateSince: null, + dateUntil: null, + }; } /* @@ -222,7 +272,19 @@ cockpit.location.go([recording.id]); } + handleDateSinceChange(date) { + this.setState({dateSince: date}); + this.props.onDateSinceChange(date); + } + + handleDateUntilChange(date) { + this.setState({dateUntil: date}); + this.props.onDateUntilChange(date); + } + render() { + const dateSince = this.state.dateSince; + const dateUntil = this.state.dateUntil; let columnTitles = [_("User"), _("Start"), _("End"), _("Duration")]; let list = this.props.list; let rows = []; @@ -238,12 +300,32 @@ navigateToItem={this.navigateToRecording.bind(this, r)}/>); } return ( - - {rows} - +
+
+ + + + + + + +
+ + + + + + + +
+
+ + {rows} + +
); } }; @@ -258,6 +340,8 @@ super(props); this.onLocationChanged = this.onLocationChanged.bind(this); this.journalctlIngest = this.journalctlIngest.bind(this); + this.handleDateSinceChange = this.handleDateSinceChange.bind(this); + this.handleDateUntilChange = this.handleDateUntilChange.bind(this); /* Journalctl instance */ this.journalctl = null; /* Recording ID journalctl instance is invoked with */ @@ -269,6 +353,8 @@ recordingList: [], /* ID of the recording to display, or null for all */ recordingID: cockpit.location.path[0] || null, + dateSince: null, + dateUntil: null, } } @@ -365,7 +451,7 @@ journalctlStart() { /* TODO Lookup UID of "tlog" user on module init */ let matches = ["_UID=987"]; - let options = {follow: true, count: "all"}; + let options = {follow: true, count: "all", since: this.state.dateSince, until: this.state.dateUntil}; if (this.state.recordingID !== null) { let parts = this.state.recordingID.split('-', 3); @@ -398,6 +484,38 @@ this.journalctl = null; } + /* + * Restarts journalctl. + * Will stop journalctl if it's running. + */ + journalctlRestart() { + if(this.journalctlIsRunning()) { + this.journalctl.stop(); + } + this.journalctlStart(); + } + + /* + * Clears previous recordings list. + * Will clear service obj recordingMap and state. + */ + clearRecordings() { + this.recordingMap = {}; + this.setState({recordingList: []}); + } + + handleDateSinceChange(date) { + this.setState({dateSince: date}); + this.clearRecordings(); + this.journalctlRestart(); + } + + handleDateUntilChange(date) { + this.setState({dateUntil: date}); + this.clearRecordings(); + this.journalctlRestart(); + } + componentDidMount() { this.journalctlStart(); cockpit.addEventListener("locationchanged", @@ -425,13 +543,19 @@ } render() { + const dateSince = this.state.dateSince; + const dateUntil = this.state.dateUntil; + if (this.state.recordingID === null) { - return ; + return ( + + ); } else { return ( - + ); } } From 82e958ff077d444d9e9ca4887953dc0e26701a95 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Wed, 19 Jul 2017 17:14:46 +0200 Subject: [PATCH 06/30] Refactor datepicker Refactor and simplify datepicker component and it's usage. More proper usage of setState with async in place. Remove unneed code. --- pkg/session_recording/recordings.jsx | 46 +++++++--------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 8954342b7331..8fe3352cd7b6 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -99,7 +99,7 @@ componentDidMount() { let funcDate = this.handleDateChange; - $(this.textInput).datepicker({ + $(this.refs.datepicker).datepicker({ autoclose: true, todayHighlight: true, format: 'yyyy-mm-dd', @@ -118,10 +118,7 @@ render() { return ( - { this.textInput = input; }} - className="form-control date" - type="text" - onChange={this.handleDateChange} /> + ); } } @@ -257,12 +254,6 @@ let RecordingList = class extends React.Component { constructor(props) { super(props); - this.handleDateSinceChange = this.handleDateSinceChange.bind(this); - this.handleDateUntilChange = this.handleDateUntilChange.bind(this); - this.state = { - dateSince: null, - dateUntil: null, - }; } /* @@ -272,19 +263,7 @@ cockpit.location.go([recording.id]); } - handleDateSinceChange(date) { - this.setState({dateSince: date}); - this.props.onDateSinceChange(date); - } - - handleDateUntilChange(date) { - this.setState({dateUntil: date}); - this.props.onDateUntilChange(date); - } - render() { - const dateSince = this.state.dateSince; - const dateUntil = this.state.dateUntil; let columnTitles = [_("User"), _("Start"), _("End"), _("Duration")]; let list = this.props.list; let rows = []; @@ -308,13 +287,13 @@ - + - + @@ -489,7 +468,7 @@ * Will stop journalctl if it's running. */ journalctlRestart() { - if(this.journalctlIsRunning()) { + if (this.journalctlIsRunning()) { this.journalctl.stop(); } this.journalctlStart(); @@ -506,14 +485,10 @@ handleDateSinceChange(date) { this.setState({dateSince: date}); - this.clearRecordings(); - this.journalctlRestart(); } handleDateUntilChange(date) { this.setState({dateUntil: date}); - this.clearRecordings(); - this.journalctlRestart(); } componentDidMount() { @@ -540,17 +515,18 @@ } this.journalctlStart(); } + if (this.state.dateSince != prevState.dateSince || this.state.dateUntil != prevState.dateUntil) { + this.clearRecordings(); + this.journalctlRestart(); + } } render() { - const dateSince = this.state.dateSince; - const dateUntil = this.state.dateUntil; - if (this.state.recordingID === null) { return ( ); } else { From 9eba192aeff5892fb1bd2c8ff828df3202f59e82 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Wed, 19 Jul 2017 17:36:48 +0200 Subject: [PATCH 07/30] Add filter by username Add filter by username component - Userpicker. Filter through TLOG_USER parameter. --- pkg/session_recording/recordings.jsx | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 8fe3352cd7b6..656baf9d3d00 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -123,6 +123,27 @@ } } + /* + * A component representing a username input text field. + * TODO make as a select / drop-down with list of exisiting users. + */ + let UserPicker = class extends React.Component { + constructor(props) { + super(props); + this.handleUsernameChange = this.handleUsernameChange.bind(this); + } + + handleUsernameChange(e) { + this.props.onUsernameChange(e.target.value); + } + + render() { + return ( + + ); + } + } + /* * A component representing a single recording view. * Properties: @@ -295,6 +316,12 @@ + + + + + +
@@ -321,6 +348,7 @@ this.journalctlIngest = this.journalctlIngest.bind(this); this.handleDateSinceChange = this.handleDateSinceChange.bind(this); this.handleDateUntilChange = this.handleDateUntilChange.bind(this); + this.handleUsernameChange = this.handleUsernameChange.bind(this); /* Journalctl instance */ this.journalctl = null; /* Recording ID journalctl instance is invoked with */ @@ -334,6 +362,8 @@ recordingID: cockpit.location.path[0] || null, dateSince: null, dateUntil: null, + /* value to filter recordings by username */ + username: null, } } @@ -430,6 +460,9 @@ journalctlStart() { /* TODO Lookup UID of "tlog" user on module init */ let matches = ["_UID=987"]; + if (this.state.username) { + matches.push("TLOG_USER=" + this.state.username); + } let options = {follow: true, count: "all", since: this.state.dateSince, until: this.state.dateUntil}; if (this.state.recordingID !== null) { @@ -491,6 +524,10 @@ this.setState({dateUntil: date}); } + handleUsernameChange(username) { + this.setState({username: username}); + } + componentDidMount() { this.journalctlStart(); cockpit.addEventListener("locationchanged", @@ -515,7 +552,9 @@ } this.journalctlStart(); } - if (this.state.dateSince != prevState.dateSince || this.state.dateUntil != prevState.dateUntil) { + if (this.state.dateSince != prevState.dateSince || + this.state.dateUntil != prevState.dateUntil || + this.state.username != prevState.username) { this.clearRecordings(); this.journalctlRestart(); } @@ -527,6 +566,7 @@ ); } else { From 291fc760783bdb225da92fefe47b290f284e3ea2 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 25 Jul 2017 09:14:11 +0200 Subject: [PATCH 08/30] Add lookup of tlog UID --- pkg/session_recording/recordings.jsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 656baf9d3d00..6744ccc65dfb 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -355,6 +355,8 @@ this.journalctlRecordingID = null; /* Recording ID -> data map */ this.recordingMap = {}; + /* tlog UID in system set in ComponentDidMount */ + this.uid = null; this.state = { /* List of recordings in start order */ recordingList: [], @@ -364,6 +366,7 @@ dateUntil: null, /* value to filter recordings by username */ username: null, + error_tlog_uid: false, } } @@ -458,8 +461,7 @@ * Assumes journalctl is not running. */ journalctlStart() { - /* TODO Lookup UID of "tlog" user on module init */ - let matches = ["_UID=987"]; + let matches = ["_UID=" + this.uid]; if (this.state.username) { matches.push("TLOG_USER=" + this.state.username); } @@ -529,7 +531,18 @@ } componentDidMount() { - this.journalctlStart(); + let proc = cockpit.spawn(["getent", "passwd", "tlog"]); + + proc.stream((data) => { + this.uid = data.split(":",3)[2]; + this.journalctlStart(); + proc.close(); + }); + + proc.fail(() => { + this.setState({error_tlog_uid: true}); + }); + cockpit.addEventListener("locationchanged", this.onLocationChanged); } @@ -561,6 +574,13 @@ } render() { + if(this.state.error_tlog_uid === true) { + return ( +
+ Error getting tlog uid from system. +
+ ); + } if (this.state.recordingID === null) { return ( Date: Tue, 25 Jul 2017 15:49:23 +0200 Subject: [PATCH 09/30] Change to TLOG_REC Use TLOG_REC field identifier instead of previous mix. --- pkg/session_recording/recordings.jsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 6744ccc65dfb..819d20651bdf 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -173,9 +173,7 @@ "/usr/bin/tlog-play", "--follow", "--reader=journal", - "-M", "_BOOT_ID=" + r.boot_id, - "-M", "TLOG_SESSION=" + r.session_id, - "-M", "_PID=" + r.pid, + "-M", "TLOG_REC=" + r.id, ], "environ": [ "TERM=xterm-256color", @@ -231,6 +229,10 @@

{_("Recording")}

+ + + + @@ -395,16 +397,13 @@ for (i = 0; i < entryList.length; i++) { let e = entryList[i]; - let boot_id = e["_BOOT_ID"]; - let session_id = e["TLOG_SESSION"]; - let process_id = e["_PID"]; + let id = e['TLOG_REC']; - /* Skip entries with missing session ID */ - if (session_id === undefined) { + /* Skip entries with missing recording ID */ + if (id === undefined) { continue; } - let id = boot_id + "-" + session_id + "-" + process_id; let ts = Math.floor( parseInt(e["__REALTIME_TIMESTAMP"], 10) / 1000); @@ -468,12 +467,7 @@ let options = {follow: true, count: "all", since: this.state.dateSince, until: this.state.dateUntil}; if (this.state.recordingID !== null) { - let parts = this.state.recordingID.split('-', 3); - matches = matches.concat([ - "_BOOT_ID=" + parts[0], - "TLOG_SESSION=" + parts[1], - "_PID=" + parts[2] - ]); + matches.push("TLOG_REC=" + this.state.recordingID); } this.journalctlRecordingID = this.state.recordingID; From a286554e3e40635fc97f2e24a2e6d142ffe65f2d Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Mon, 31 Jul 2017 16:23:10 +0200 Subject: [PATCH 10/30] Use className attr in recording list table Change class to className --- pkg/session_recording/recordings.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 819d20651bdf..b2fe1db22553 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -304,8 +304,8 @@ return (
-
{_("ID")}{r.id}
{_("Boot ID")} {r.boot_id}
- +
+ @@ -324,7 +324,7 @@ - +
Date: Mon, 31 Jul 2017 16:50:47 +0200 Subject: [PATCH 11/30] Restyle datepicker and userpicker Match Cockpit design standard --- pkg/session_recording/recordings.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index b2fe1db22553..c27e4bd87439 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -118,7 +118,10 @@ render() { return ( - +
+ + +
); } } @@ -139,7 +142,9 @@ render() { return ( - +
+ +
); } } From d06fd7436efa8fec09e10a0960533c30bc4de4f4 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 1 Aug 2017 17:44:53 +0200 Subject: [PATCH 12/30] Add styling for single recording --- pkg/session_recording/recordings.jsx | 101 +++++++++++++++++---------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index c27e4bd87439..6b68d429823f 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -231,43 +231,70 @@ } return ( -
-

{_("Recording")}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{_("ID")}{r.id}
{_("Boot ID")}{r.boot_id}
{_("Session ID")}{r.session_id}
{_("PID")}{r.pid}
{_("Start")}{formatDateTime(r.start)}
{_("End")}{formatDateTime(r.end)}
{_("Duration")}{formatDuration(r.end - r.start)}
{_("User")}{r.user}
- {terminal} +
+
+
+
    +
  1. Session Recording
  2. +
  3. Session
  4. +
+
+
+
+
+
+
+ {_("Recording")} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{_("ID")}{r.id}
{_("Boot ID")}{r.boot_id}
{_("Session ID")}{r.session_id}
{_("PID")}{r.pid}
{_("Start")}{formatDateTime(r.start)}
{_("End")}{formatDateTime(r.end)}
{_("Duration")}{formatDuration(r.end - r.start)}
{_("User")}{r.user}
+
+
+
+
+
+
+ {_("Player")} +
+
+ {terminal} +
+
+
+
); } From 80210bdb347690fac7a040a62f41a8e7f64b7800 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Wed, 2 Aug 2017 15:32:01 +0200 Subject: [PATCH 13/30] Add restart playback button --- pkg/session_recording/recordings.jsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 6b68d429823f..a25b34b8195a 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -158,6 +158,7 @@ let Recording = class extends React.Component { constructor(props) { super(props); + this.restartPlayback = this.restartPlayback.bind(this); this.state = { channel: null, }; @@ -189,6 +190,13 @@ }); } + restartPlayback() { + if (this.state.channel != null) { + this.state.channel.close(); + } + this.setState({channel: this.createChannel()}); + } + componentDidMount() { this.setState({channel: this.createChannel()}); } @@ -288,6 +296,10 @@
{_("Player")} +
+ +
{terminal} From c8bca4949e4a567fcf65e7814087b7f4077d0496 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 15 Aug 2017 10:31:52 +0200 Subject: [PATCH 14/30] Copy cockpit terminal component for expanding --- pkg/session_recording/terminal.jsx | 164 +++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 pkg/session_recording/terminal.jsx diff --git a/pkg/session_recording/terminal.jsx b/pkg/session_recording/terminal.jsx new file mode 100644 index 000000000000..9722f3fe9111 --- /dev/null +++ b/pkg/session_recording/terminal.jsx @@ -0,0 +1,164 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2016 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 . + */ + +(function() { + "use strict"; + + var React = require("react"); + var Term = require("term"); + + require("console.css"); + + /* + * A terminal component that communicates over a cockpit channel. + * + * The only required property is 'channel', which must point to a cockpit + * stream channel. + * + * The size of the terminal can be set with the 'rows' and 'cols' + * properties. If those properties are not given, the terminal will fill + * its container. + * + * If the 'onTitleChanged' callback property is set, it will be called whenever + * the title of the terminal changes. + * + * Call focus() to set the input focus on the terminal. + */ + var Terminal = React.createClass({ + propTypes: { + cols: React.PropTypes.number, + rows: React.PropTypes.number, + channel: React.PropTypes.object.isRequired, + onTitleChanged: React.PropTypes.func + }, + + componentWillMount: function () { + var term = new Term({ + cols: this.state.cols || 80, + rows: this.state.rows || 25, + screenKeys: true, + useStyle: true + }); + + term.on('data', function(data) { + if (this.props.channel.valid) + this.props.channel.send(data); + }.bind(this)); + + if (this.props.onTitleChanged) + term.on('title', this.props.onTitleChanged); + + this.setState({ terminal: term }); + }, + + componentDidMount: function () { + this.state.terminal.open(this.refs.terminal); + this.connectChannel(); + + if (!this.props.rows) { + window.addEventListener('resize', this.onWindowResize); + this.onWindowResize(); + } + }, + + componentWillUpdate: function (nextProps, nextState) { + if (nextState.cols !== this.state.cols || nextState.rows !== this.state.rows) { + this.state.terminal.resize(nextState.cols, nextState.rows); + this.props.channel.control({ + window: { + rows: nextState.rows, + cols: nextState.cols + } + }); + } + + if (nextProps.channel !== this.props.channel) { + this.state.terminal.reset(); + this.disconnectChannel(); + } + }, + + componentDidUpdate: function (prevProps) { + if (prevProps.channel !== this.props.channel) + this.connectChannel(); + }, + + render: function () { + // ensure react never reuses this div by keying it with the terminal widget + return
; + }, + + componentWillUnmount: function () { + this.disconnectChannel(); + this.state.terminal.destroy(); + }, + + onChannelMessage: function (event, data) { + this.state.terminal.write(data); + }, + + onChannelClose: function (event, options) { + var term = this.state.terminal; + term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n'); + term.cursorHidden = true; + term.refresh(term.y, term.y); + }, + + connectChannel: function () { + var channel = this.props.channel; + if (channel && channel.valid) { + channel.addEventListener('message', this.onChannelMessage.bind(this)); + channel.addEventListener('close', this.onChannelClose.bind(this)); + } + }, + + disconnectChannel: function () { + if (this.props.channel) { + this.props.channel.removeEventListener('message', this.onChannelMessage); + this.props.channel.removeEventListener('close', this.onChannelClose); + } + }, + + focus: function () { + if (this.state.terminal) + this.state.terminal.focus(); + }, + + onWindowResize: function () { + var padding = 2 * 11; + var node = this.getDOMNode(); + var terminal = this.refs.terminal.querySelector('.terminal'); + + var ch = document.createElement('div'); + ch.textContent = 'M'; + terminal.appendChild(ch); + var height = ch.offsetHeight; // offsetHeight is only correct for block elements + ch.style.display = 'inline'; + var width = ch.offsetWidth; + terminal.removeChild(ch); + + this.setState({ + rows: Math.floor((node.parentElement.clientHeight - padding) / height), + cols: Math.floor((node.parentElement.clientWidth - padding) / width) + }); + } + }); + + module.exports = { Terminal: Terminal }; +}()); From cc2340f1b3881374e2cf8d72036e77a0b7c89229 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Thu, 10 Aug 2017 17:10:17 +0200 Subject: [PATCH 15/30] Make terminal resizable Make drag'n'drop resize feature for terminal --- package.json | 1 + pkg/session_recording/recordings.jsx | 16 ++++++--- pkg/session_recording/terminal.jsx | 54 +++++++++++++++++++--------- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index f7c237bd270f..623443659deb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "d3": "3.5.17", "jquery": "2.2.4", "jquery-flot": "0.8.3", + "jquery-resizable": "1.0.6", "kubernetes-container-terminal": "1.0.3", "kubernetes-object-describer": "1.1.4", "kubernetes-topology-graph": "0.0.23", diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index a25b34b8195a..4e181ed1c3d7 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -26,7 +26,7 @@ let Journal = require("journal"); let React = require("react"); let Listing = require("cockpit-components-listing.jsx"); - let Terminal = require("cockpit-components-terminal.jsx"); + let Terminal = require("./terminal.jsx"); require("bootstrap-datepicker/dist/js/bootstrap-datepicker"); @@ -232,12 +232,18 @@ if (this.state.channel) { terminal = (); } else { terminal = Loading...; } + let style = { + width: 'auto', + height: 'auto', + overflow: 'hidden', + 'min-width': '300px' + }; + return (
@@ -292,7 +298,7 @@
-
+
{_("Player")} @@ -302,7 +308,9 @@
- {terminal} +
+ {terminal} +
diff --git a/pkg/session_recording/terminal.jsx b/pkg/session_recording/terminal.jsx index 9722f3fe9111..3d751286848f 100644 --- a/pkg/session_recording/terminal.jsx +++ b/pkg/session_recording/terminal.jsx @@ -22,8 +22,11 @@ var React = require("react"); var Term = require("term"); + let $ = require("jquery"); require("console.css"); + require("jquery-resizable"); + require("jquery-resizable/resizable.css"); /* * A terminal component that communicates over a cockpit channel. @@ -71,6 +74,16 @@ this.state.terminal.open(this.refs.terminal); this.connectChannel(); + let term = this.refs.terminal; + let onWindowResize = this.onWindowResize; + + $( function() { $(term).resizable({ + direction: ['right', 'bottom'], + stop: function() { + onWindowResize(); + }, + }); }); + if (!this.props.rows) { window.addEventListener('resize', this.onWindowResize); this.onWindowResize(); @@ -100,8 +113,12 @@ }, render: function () { + let style = { + 'min-width': '300px', + 'min-height': '100px', + } // ensure react never reuses this div by keying it with the terminal widget - return
; + return
; }, componentWillUnmount: function () { @@ -141,22 +158,25 @@ }, onWindowResize: function () { - var padding = 2 * 11; - var node = this.getDOMNode(); - var terminal = this.refs.terminal.querySelector('.terminal'); - - var ch = document.createElement('div'); - ch.textContent = 'M'; - terminal.appendChild(ch); - var height = ch.offsetHeight; // offsetHeight is only correct for block elements - ch.style.display = 'inline'; - var width = ch.offsetWidth; - terminal.removeChild(ch); - - this.setState({ - rows: Math.floor((node.parentElement.clientHeight - padding) / height), - cols: Math.floor((node.parentElement.clientWidth - padding) / width) - }); + if(this.refs) { + var padding = 2 * 11; + var node = this.getDOMNode(); + var terminal = this.refs.terminal.querySelector('.terminal'); + + var ch = document.createElement('div'); + ch.textContent = 'M'; + terminal.appendChild(ch); + var height = ch.offsetHeight; // offsetHeight is only correct for block elements + ch.style.display = 'inline'; + var width = ch.offsetWidth; + terminal.removeChild(ch); + + this.setState({ + rows: Math.floor((node.parentElement.clientHeight - padding) / height), + cols: Math.floor((node.parentElement.clientWidth - padding) / width) + }); + } + return; } }); From b831514a8bf9638eaf505cdf7c055a3a2fdf585a Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Mon, 14 Aug 2017 16:31:25 +0200 Subject: [PATCH 16/30] Improve design Fix breadcrumbs link, grammar, recording list cursor --- pkg/session_recording/recordings.css | 4 ++++ pkg/session_recording/recordings.jsx | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/session_recording/recordings.css b/pkg/session_recording/recordings.css index b326f39c377f..6bf6daf5c324 100644 --- a/pkg/session_recording/recordings.css +++ b/pkg/session_recording/recordings.css @@ -306,3 +306,7 @@ a.disabled { margin: 0px; white-space: pre-wrap; } + +.listing > tbody > tr { + cursor: pointer; +} diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 4e181ed1c3d7..39357bc2a955 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -159,6 +159,7 @@ constructor(props) { super(props); this.restartPlayback = this.restartPlayback.bind(this); + this.goBackToList = this.goBackToList.bind(this); this.state = { channel: null, }; @@ -201,6 +202,10 @@ this.setState({channel: this.createChannel()}); } + goBackToList() { + cockpit.location.go('/'); + } + componentDidUpdate(prevProps) { if (this.props.recording != prevProps.recording) { let channel; @@ -249,7 +254,7 @@
@@ -623,7 +628,7 @@ if(this.state.error_tlog_uid === true) { return (
- Error getting tlog uid from system. + Error getting tlog UID from system.
); } From 0877af62040938d2746c20d3b31d3340bf16e349 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Fri, 18 Aug 2017 14:00:21 +0200 Subject: [PATCH 17/30] Add URL response for filters Add URL response for filters. Fix values disappearing bug. Fix #13, fix #15. --- pkg/session_recording/recordings.jsx | 48 ++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 39357bc2a955..dcf7c38cd7ee 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -119,7 +119,8 @@ render() { return (
- +
); @@ -143,7 +144,8 @@ render() { return (
- +
); } @@ -203,7 +205,11 @@ } goBackToList() { - cockpit.location.go('/'); + if (cockpit.location.path[0]) { + cockpit.location.go([], cockpit.location.options); + } else { + cockpit.location.go('/'); + } } componentDidUpdate(prevProps) { @@ -340,7 +346,7 @@ * Set the cockpit location to point to the specified recording. */ navigateToRecording(recording) { - cockpit.location.go([recording.id]); + cockpit.location.go([recording.id], cockpit.location.options); } render() { @@ -367,19 +373,22 @@ - + - + - + @@ -421,10 +430,10 @@ recordingList: [], /* ID of the recording to display, or null for all */ recordingID: cockpit.location.path[0] || null, - dateSince: null, - dateUntil: null, + dateSince: cockpit.location.options.dateSince || null, + dateUntil: cockpit.location.options.dateUntil || null, /* value to filter recordings by username */ - username: null, + username: cockpit.location.options.username || null, error_tlog_uid: false, } } @@ -441,7 +450,12 @@ * displayed recording ID. */ onLocationChanged() { - this.setState({recordingID: cockpit.location.path[0] || null}); + this.setState({ + recordingID: cockpit.location.path[0] || null, + dateSince: cockpit.location.options.dateSince || null, + dateUntil: cockpit.location.options.dateUntil || null, + username: cockpit.location.options.username || null, + }); } /* @@ -570,15 +584,15 @@ } handleDateSinceChange(date) { - this.setState({dateSince: date}); + cockpit.location.go([], $.extend(cockpit.location.options, { dateSince: date })); } handleDateUntilChange(date) { - this.setState({dateUntil: date}); + cockpit.location.go([], $.extend(cockpit.location.options, { dateUntil: date })); } handleUsernameChange(username) { - this.setState({username: username}); + cockpit.location.go([], $.extend(cockpit.location.options, { username: username })); } componentDidMount() { @@ -635,9 +649,9 @@ if (this.state.recordingID === null) { return ( ); } else { From 9a8be61b0ca7aa741a2d19828e3ed816b2a884c7 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 29 Aug 2017 09:44:51 +0200 Subject: [PATCH 18/30] Add buttons and hotkeys for playback control --- pkg/session_recording/recordings.css | 8 ++ pkg/session_recording/recordings.jsx | 130 ++++++++++++++++++++++++--- pkg/session_recording/terminal.jsx | 4 + 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/pkg/session_recording/recordings.css b/pkg/session_recording/recordings.css index 6bf6daf5c324..8b84b527c12f 100644 --- a/pkg/session_recording/recordings.css +++ b/pkg/session_recording/recordings.css @@ -310,3 +310,11 @@ a.disabled { .listing > tbody > tr { cursor: pointer; } + +.margin-right-btn { + margin-right: 10px; +} + +.play-btn { + min-width: 34px; +} diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index dcf7c38cd7ee..bd4f55c9b02f 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -161,9 +161,19 @@ constructor(props) { super(props); this.restartPlayback = this.restartPlayback.bind(this); + this.playPauseToggle = this.playPauseToggle.bind(this); + this.speedUp = this.speedUp.bind(this); + this.speedDown = this.speedDown.bind(this); + this.speedReset = this.speedReset.bind(this); this.goBackToList = this.goBackToList.bind(this); + this.sendToTerm = this.sendToTerm.bind(this); + this.fastForwardToEnd = this.fastForwardToEnd.bind(this); + this.skipFrame = this.skipFrame.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.state = { channel: null, + paused: true, + speed_exp: 0, }; } @@ -176,14 +186,20 @@ if (!r) { return null; } + let spawn = [ + "/usr/bin/tlog-play", + "--persist", + "--follow", + "--reader=journal", + "-M", "TLOG_REC=" + r.id, + "--speed=" + Math.pow(2, this.state.speed_exp), + ]; + if (this.state.paused) { + spawn.push("--paused"); + } return cockpit.channel({ "payload": "stream", - "spawn": [ - "/usr/bin/tlog-play", - "--follow", - "--reader=journal", - "-M", "TLOG_REC=" + r.id, - ], + "spawn": spawn, "environ": [ "TERM=xterm-256color", "PATH=/sbin:/bin:/usr/sbin:/usr/bin" @@ -193,6 +209,37 @@ }); } + sendToTerm(value) { + this.refs.terminal.send(value); + } + + playPauseToggle() { + this.setState({paused: !this.state.paused}); + this.sendToTerm(' '); + } + + speedUp() { + let speed_exp = this.state.speed_exp; + if (speed_exp < 4) { + this.setState({speed_exp: speed_exp + 1}); + this.sendToTerm('}'); + } + } + + speedDown() { + let speed_exp = this.state.speed_exp; + if (speed_exp > -4) { + this.setState({speed_exp: speed_exp - 1}); + this.sendToTerm('{'); + } + } + + speedReset() { + this.setState({speed_exp: 0}); + // Backspace + this.sendToTerm('\x7f'); + } + restartPlayback() { if (this.state.channel != null) { this.state.channel.close(); @@ -200,6 +247,33 @@ this.setState({channel: this.createChannel()}); } + fastForwardToEnd() { + this.sendToTerm('G'); + } + + skipFrame() { + this.sendToTerm('.'); + } + + handleKeyDown(event) { + let keyCodesFuncs = { + "p": this.playPauseToggle, + "}": this.speedUp, + "{": this.speedDown, + "Backspace": this.speedReset, + ".": this.skipFrame, + "G": this.fastForwardToEnd, + "R": this.restartPlayback, + }; + if (keyCodesFuncs[event.key]) { + (keyCodesFuncs[event.key](event)); + } + } + + componentWillMount() { + window.addEventListener("keydown", this.handleKeyDown, false); + } + componentDidMount() { this.setState({channel: this.createChannel()}); } @@ -228,6 +302,7 @@ } componentWillUnmount() { + window.removeEventListener("keydown", this.handleKeyDown, false); if (this.state.channel != null) { this.state.channel.close(); } @@ -255,6 +330,18 @@ 'min-width': '300px' }; + let speed = (() => { + let exp = this.state.speed_exp; + let factor = Math.pow(2, Math.abs(exp)); + if (exp > 0) { + return 'x' + factor; + } else if (exp < 0) { + return '/' + factor; + } else { + return ''; + } + })(); + return (
@@ -313,16 +400,39 @@
{_("Player")} -
- -
{terminal}
+
+ + + + + + + + {speed} +
diff --git a/pkg/session_recording/terminal.jsx b/pkg/session_recording/terminal.jsx index 3d751286848f..f342c986e99f 100644 --- a/pkg/session_recording/terminal.jsx +++ b/pkg/session_recording/terminal.jsx @@ -177,6 +177,10 @@ }); } return; + }, + + send: function(value) { + this.state.terminal.send(value); } }); From 3c228666dee0ba16d2c3c1180340d620fecb8789 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Thu, 31 Aug 2017 09:57:14 +0200 Subject: [PATCH 19/30] Fix console error for ended session --- pkg/session_recording/terminal.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/session_recording/terminal.jsx b/pkg/session_recording/terminal.jsx index f342c986e99f..e51722ed50bc 100644 --- a/pkg/session_recording/terminal.jsx +++ b/pkg/session_recording/terminal.jsx @@ -127,7 +127,9 @@ }, onChannelMessage: function (event, data) { - this.state.terminal.write(data); + if(this.state.terminal) { + this.state.terminal.write(data); + } }, onChannelClose: function (event, options) { From 27e3d4a9ed544a5cc7478cf26a49627396424020 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Thu, 31 Aug 2017 14:48:21 +0200 Subject: [PATCH 20/30] Fix "if" code style --- pkg/session_recording/recordings.jsx | 2 +- pkg/session_recording/terminal.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index bd4f55c9b02f..266128f74b38 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -749,7 +749,7 @@ } render() { - if(this.state.error_tlog_uid === true) { + if (this.state.error_tlog_uid === true) { return (
Error getting tlog UID from system. diff --git a/pkg/session_recording/terminal.jsx b/pkg/session_recording/terminal.jsx index e51722ed50bc..7984bc512658 100644 --- a/pkg/session_recording/terminal.jsx +++ b/pkg/session_recording/terminal.jsx @@ -127,7 +127,7 @@ }, onChannelMessage: function (event, data) { - if(this.state.terminal) { + if (this.state.terminal) { this.state.terminal.write(data); } }, @@ -160,7 +160,7 @@ }, onWindowResize: function () { - if(this.refs) { + if (this.refs) { var padding = 2 * 11; var node = this.getDOMNode(); var terminal = this.refs.terminal.querySelector('.terminal'); From b5a7d904a7007e4a496780af6283362be6414c2d Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Tue, 12 Sep 2017 15:51:29 +0200 Subject: [PATCH 21/30] Add reason error code to close channels --- pkg/session_recording/recordings.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 266128f74b38..9c23b14ddf30 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -242,7 +242,7 @@ restartPlayback() { if (this.state.channel != null) { - this.state.channel.close(); + this.state.channel.close("terminated"); } this.setState({channel: this.createChannel()}); } @@ -290,7 +290,7 @@ if (this.props.recording != prevProps.recording) { let channel; if (this.state.channel != null) { - this.state.channel.close(); + this.state.channel.close("terminated"); } if (this.props.recording == null) { channel = null; @@ -304,7 +304,7 @@ componentWillUnmount() { window.removeEventListener("keydown", this.handleKeyDown, false); if (this.state.channel != null) { - this.state.channel.close(); + this.state.channel.close("terminated"); } } From aff898d3fca33974441383b73f762c9fef41df75 Mon Sep 17 00:00:00 2001 From: Kyrylo Gliebov Date: Mon, 4 Sep 2017 17:46:12 +0200 Subject: [PATCH 22/30] Add recording list sorting Fix #26 --- pkg/session_recording/recordings.css | 17 +++++++ pkg/session_recording/recordings.jsx | 69 ++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/pkg/session_recording/recordings.css b/pkg/session_recording/recordings.css index 8b84b527c12f..94d1ebaf9a3e 100644 --- a/pkg/session_recording/recordings.css +++ b/pkg/session_recording/recordings.css @@ -318,3 +318,20 @@ a.disabled { .play-btn { min-width: 34px; } + +.sort { + cursor: pointer; +} + +.sort span { + text-decoration: underline; +} + +.sort-icon { + width:7px; + display:inline-block; +} + +table.listing-ct > thead th:last-child, tr.listing-ct-item td:last-child { + text-align: left !important; +} diff --git a/pkg/session_recording/recordings.jsx b/pkg/session_recording/recordings.jsx index 9c23b14ddf30..24a7f74445fa 100644 --- a/pkg/session_recording/recordings.jsx +++ b/pkg/session_recording/recordings.jsx @@ -450,6 +450,52 @@ let RecordingList = class extends React.Component { constructor(props) { super(props); + this.handleColumnClick = this.handleColumnClick.bind(this); + this.getSortedList = this.getSortedList.bind(this); + this.drawSortDir = this.drawSortDir.bind(this); + this.state = { + sorting_field: "start", + sorting_asc: true, + }; + } + + drawSortDir() { + $('#sort_arrow').remove(); + let type = this.state.sorting_asc ? "asc" : "desc"; + let arrow = ''; + $(this.refs[this.state.sorting_field]).append(arrow); + } + + handleColumnClick(event) { + if(this.state.sorting_field === event.currentTarget.id) { + this.setState({sorting_asc: !this.state.sorting_asc}); + } + else { + this.setState({ + sorting_field: event.currentTarget.id, + sorting_asc: 'asc' + }); + } + } + + getSortedList() { + let field = this.state.sorting_field; + let asc = this.state.sorting_asc; + let list = this.props.list.slice(); + + if (this.state.sorting_field != null) { + if (asc) { + list.sort(function(a, b) { + return a[field] > b[field]; + }); + } else { + list.sort(function(a, b) { + return a[field] < b[field]; + }); + } + } + + return list; } /* @@ -459,10 +505,24 @@ cockpit.location.go([recording.id], cockpit.location.options); } + componentDidUpdate() { + this.drawSortDir(); + } + render() { - let columnTitles = [_("User"), _("Start"), _("End"), _("Duration")]; - let list = this.props.list; + let columnTitles = [ + (
{_("User")}
), + (
{_("Start")}
), + (
{_("End")}
), + (
{_("Duration")}
), + ]; + let list = this.getSortedList(); let rows = []; + for (let i = 0; i < list.length; i++) { let r = list[i]; let columns = [r.user, @@ -600,7 +660,8 @@ pid: parseInt(e["_PID"], 10), start: ts, /* FIXME Should be start + message duration */ - end: ts}; + end: ts, + duration: 0}; /* Map the recording */ this.recordingMap[id] = r; /* Insert the recording in order */ @@ -612,9 +673,11 @@ /* Adjust existing recording */ if (ts > r.end) { r.end = ts; + r.duration = r.end - r.start; } if (ts < r.start) { r.start = ts; + r.duration = r.end - r.start; /* Find the recording in the list */ for (j = recordingList.length - 1; j >= 0 && recordingList[j] != r; From 6578d7d11b52720f3f7f153675bc61a9dee54830 Mon Sep 17 00:00:00 2001 From: Nikolai Kondrashov Date: Wed, 13 Sep 2017 10:21:23 +0300 Subject: [PATCH 23/30] Replace tlog-play with browser-based player --- pkg/session_recording/player.jsx | 823 +++++++++++++++++++++++++++ pkg/session_recording/recordings.jsx | 204 +------ 2 files changed, 831 insertions(+), 196 deletions(-) create mode 100644 pkg/session_recording/player.jsx diff --git a/pkg/session_recording/player.jsx b/pkg/session_recording/player.jsx new file mode 100644 index 000000000000..83e6046fcb80 --- /dev/null +++ b/pkg/session_recording/player.jsx @@ -0,0 +1,823 @@ +/* + * 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 . + */ + +(function() { + "use strict"; + + let cockpit = require("cockpit"); + let _ = cockpit.gettext; + let React = require("react"); + let Term = require("term"); + let Journal = require("journal"); + let $ = require("jquery"); + + require("console.css"); + + /* + * Get an object field, verifying its presence and type. + */ + let getValidField = function (object, field, type) { + let value; + if (!(field in object)) { + throw "\"" + field + "\" field is missing"; + } + value = object[field]; + if (typeof(value) != type) { + throw "invalid \"" + field + "\" field type: " + typeof(value); + } + return value; + } + + /* + * An auto-loading buffer of recording's packets. + */ + let PacketBuffer = class { + /* + * Initialize a buffer. + */ + constructor(matchList) { + this.handleError = this.handleError.bind(this); + this.handleStream = this.handleStream.bind(this); + this.handleDone = this.handleDone.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; + /* + * 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}); + 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; + } + + /* + * 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) { + let 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 = []; + } + + /* + * 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) { + throw "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: 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: 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) { + throw("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: 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) { + throw("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: 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; + } + } + + if (in_txt_pos < in_txt.length) { + throw "extra input present"; + } + if (out_txt_pos < out_txt.length) { + throw "extra output present"; + } + + if (io.length > 0) { + this.addPacket({pos: this.pos, + is_io: true, + is_output: is_output, + io: io.join()}); + } + } + + /* + * Parse packets out of a tlog message and add them to the buffer. + */ + parseMessage(message) { + let matches; + let ver; + let id; + let pos; + + /* Check version */ + ver = getValidField(message, "ver", "string"); + matches = ver.match("^(\\d+)\\.(\\d+)$"); + if (matches === null || matches[1] > 2) { + throw "\"ver\" field has invalid value: " + ver; + } + + /* TODO Perhaps check host, rec, user, term, and session fields */ + + /* Extract message ID */ + id = getValidField(message, "id", "number"); + if (id <= this.id) { + throw "out of order \"id\" field value: " + id; + } + + /* Extract message time position */ + pos = getValidField(message, "pos", "number"); + if (pos < this.message_pos) { + throw "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( + getValidField(message, "timing", "string"), + getValidField(message, "in_txt", "string"), + 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 { + this.parseMessage(JSON.parse(e['MESSAGE'])); + } catch (error) { + this.handleError(error); + return; + } + } + } + + /* + * Handle journalctl "done" event. + */ + handleDone() { + this.done = true; + this.journalctl.stop(); + /* Continue with the "following" run */ + this.journalctl = Journal.journalctl( + this.matchList, + {cursor: this.cursor, + follow: true, count: "all"}); + this.journalctl.fail(this.handleError); + this.journalctl.stream(this.handleStream); + /* NOTE: no "done" handler on purpose */ + } + }; + + let Player = class extends React.Component { + constructor(props) { + super(props); + + this.handleTimeout = this.handleTimeout.bind(this); + this.handlePacket = this.handlePacket.bind(this); + this.handleError = this.handleError.bind(this); + this.handleTitleChange = this.handleTitleChange.bind(this); + this.rewindToStart = this.rewindToStart.bind(this); + this.playPauseToggle = this.playPauseToggle.bind(this); + this.speedUp = this.speedUp.bind(this); + this.speedDown = this.speedDown.bind(this); + this.speedReset = this.speedReset.bind(this); + this.fastForwardToEnd = this.fastForwardToEnd.bind(this); + this.skipFrame = this.skipFrame.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + + this.state = { + cols: 80, + rows: 25, + title: _("Player"), + term: null, + paused: true, + /* Speed exponent */ + speedExp: 0, + }; + + /* Auto-loading buffer of recording's packets */ + this.buf = new PacketBuffer(this.props.matchList); + + /* Current recording time, ms */ + this.recTS = 0; + /* Corresponding local time, ms */ + this.locTS = 0; + + /* Index of the current packet */ + this.pktIdx = 0; + /* Current packet, or null if not retrieved */ + this.pkt = null; + /* Timeout ID of the current packet, null if none */ + this.timeout = null; + + /* True if the next packet should be output without delay */ + this.skip = false; + /* Playback speed */ + this.speed = 1; + /* + * Timestamp playback should fast-forward to. + * Recording time, ms, or null if not fast-forwarding. + */ + this.fastForwardTo = null; + } + + reset() { + /* Clear any pending timeouts */ + this.clearTimeout(); + + /* Reset the terminal */ + this.state.term.reset(); + + /* Move to beginning of buffer */ + this.pktIdx = 0; + /* No packet loaded */ + this.pkt = null; + + /* We are not skipping */ + this.skip = false; + /* We are not fast-forwarding */ + this.fastForwardTo = null; + + /* Move to beginning of recording */ + this.recTS = 0; + /* Start the playback time */ + this.locTS = performance.now(); + + /* Wait for the first packet */ + this.awaitPacket(0); + } + + componentWillMount() { + let term = new Term({ + cols: this.state.cols, + rows: this.state.rows, + screenKeys: true, + useStyle: true + }); + + term.on('title', this.handleTitleChange); + + this.setState({ term: term }); + + window.addEventListener("keydown", this.handleKeyDown, false); + } + + componentDidMount() { + /* Open the terminal */ + this.state.term.open(this.refs.term); + /* Reset playback */ + this.reset(); + } + + /* Subscribe for a packet at specified index */ + awaitPacket(idx) { + this.buf.awaitPacket(idx).done(this.handlePacket). + fail(this.handleError); + } + + /* Set next packet timeout, ms */ + setTimeout(ms) { + this.timeout = window.setTimeout(this.handleTimeout, ms); + } + + /* Clear next packet timeout */ + clearTimeout() { + if (this.timeout !== null) { + window.clearTimeout(this.timeout); + this.timeout = null; + } + } + + /* Handle packet retrieval error */ + handleError(error) { + if (error !== null) { + console.warn(error); + } + } + + /* Handle packet retrieval success */ + handlePacket() { + this.sync(); + } + + /* Handle arrival of packet output time */ + handleTimeout() { + this.timeout = null; + this.sync(); + } + + /* Handle terminal title change */ + handleTitleChange(title) { + this.setState({ title: _("Player") + ": " + title }); + } + + /* Synchronize playback */ + sync() { + let locDelay; + + /* We are already called, don't call us with timeout */ + this.clearTimeout(); + + /* Forever */ + for (;;) { + /* Get another packet to output, if none */ + for (; this.pkt === null; this.pktIdx++) { + let pkt = this.buf.pktList[this.pktIdx]; + /* If there are no more packets */ + if (pkt === undefined) { + /* + * If we're done loading existing packets and we were + * fast-forwarding. + */ + if (this.fastForwardTo != null && this.buf.isDone()) { + /* Stop fast-forwarding */ + this.fastForwardTo = null; + } + /* Call us when we get one */ + this.awaitPacket(); + return; + } + + /* Skip packets we don't output */ + if (!pkt.is_io || !pkt.is_output) { + continue; + } + + this.pkt = pkt; + } + + /* Get the current local time */ + let nowLocTS = performance.now(); + + /* Ignore the passed time, if we're paused */ + if (this.state.paused) { + locDelay = 0; + } else { + locDelay = nowLocTS - this.locTS; + } + + /* Sync to the local time */ + this.locTS = nowLocTS; + + /* If we are skipping one packet's delay */ + if (this.skip) { + this.skip = false; + this.recTS = this.pkt.pos; + /* Else, if we are fast-forwarding */ + } else if (this.fastForwardTo !== null) { + /* If we haven't reached fast-forward destination */ + if (this.pkt.pos < this.fastForwardTo) { + this.recTS = this.pkt.pos; + } else { + this.recTS = this.fastForwardTo; + this.fastForwardTo = null; + continue; + } + /* Else, if we are paused */ + } else if (this.state.paused) { + return; + } else { + this.recTS += locDelay * this.speed; + let pktRecDelay = this.pkt.pos - this.recTS; + let pktLocDelay = pktRecDelay / this.speed; + /* If we're more than 5 ms early for this packet */ + if (pktLocDelay > 5) { + /* Call us again on time, later */ + this.setTimeout(pktLocDelay); + return; + } + } + + /* Output the packet */ + this.state.term.write(this.pkt.io); + + /* We no longer have a packet */ + this.pkt = null; + } + } + + playPauseToggle() { + this.setState({paused: !this.state.paused}); + } + + speedUp() { + let speedExp = this.state.speedExp; + if (speedExp < 4) { + this.setState({speedExp: speedExp + 1}); + } + } + + speedDown() { + let speedExp = this.state.speedExp; + if (speedExp > -4) { + this.setState({speedExp: speedExp - 1}); + } + } + + speedReset() { + this.setState({speedExp: 0}); + } + + rewindToStart() { + this.reset(); + this.sync(); + } + + fastForwardToEnd() { + this.fastForwardTo = Infinity; + this.sync(); + } + + skipFrame() { + this.skip = true; + this.sync(); + } + + handleKeyDown(event) { + let keyCodesFuncs = { + "p": this.playPauseToggle, + "}": this.speedUp, + "{": this.speedDown, + "Backspace": this.speedReset, + ".": this.skipFrame, + "G": this.fastForwardToEnd, + "R": this.rewindToStart, + }; + if (keyCodesFuncs[event.key]) { + (keyCodesFuncs[event.key](event)); + } + } + + componentWillUpdate(nextProps, nextState) { + /* If we changed pause state or speed exponent */ + if (nextState.paused != this.state.paused || + nextState.speedExp != this.state.speedExp) { + this.sync(); + } + } + + componentDidUpdate(prevProps, prevState) { + /* If we changed pause state or speed exponent */ + if (this.state.paused != prevState.paused || + this.state.speedExp != prevState.speedExp) { + this.speed = Math.pow(2, this.state.speedExp); + this.sync(); + } + } + + render() { + let speedExp = this.state.speedExp; + let speedFactor = Math.pow(2, Math.abs(speedExp)); + let speedStr; + + if (speedExp > 0) { + speedStr = "x" + speedFactor; + } else if (speedExp < 0) { + speedStr = "/" + speedFactor; + } else { + speedStr = ""; + } + + // ensure react never reuses this div by keying it with the terminal widget + return ( +
+
+ {this.state.title} +
+
+
+
+
+