starter-kit/src/recordings.jsx
2018-10-10 17:59:55 +02:00

852 lines
32 KiB
JavaScript

/*
* 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 <http://www.gnu.org/licenses/>.
*/
(function() {
"use strict";
let $ = require("jquery");
let cockpit = require("cockpit");
let _ = cockpit.gettext;
let moment = require("moment");
let Journal = require("journal");
let React = require("react");
let Listing = require("cockpit-components-listing.jsx");
let Player = require("./player.jsx");
require("bootstrap-datetime-picker/js/bootstrap-datetimepicker.js");
require("bootstrap-datetime-picker/css/bootstrap-datetimepicker.css");
/*
* Convert a number to integer number string and pad with zeroes to
* specified width.
*/
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;
}
return ((i < 0) ? '-' : '') + s;
}
/*
* Format date and time for a number of milliseconds since Epoch.
*/
let formatDateTime = function (ms) {
let d = new Date(ms);
return (
padInt(d.getFullYear(), 4) + '-' +
padInt(d.getMonth() + 1, 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.
*/
let formatDuration = function (ms) {
let v = Math.floor(ms / 1000);
let s = Math.floor(v % 60);
v = Math.floor(v / 60);
let m = Math.floor(v % 60);
v = Math.floor(v / 60);
let h = Math.floor(v % 24);
let 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;
};
let parseDate = function(date) {
let regex = new RegExp(/^\s*(\d\d\d\d-\d\d-\d\d)(\s+(\d\d:\d\d(:\d\d)?))?\s*$/);
let captures = regex.exec(date);
if (captures != null) {
let date = captures[1];
if (captures[3]) {
date = date + " " + captures[3];
}
if (moment(date, ["YYYY-M-D H:m:s", "YYYY-M-D H:m", "YYYY-M-D"], true).isValid()) {
return date;
}
}
if (date === "" || date === null) {
return true;
}
return false;
}
/*
* A component representing a date & time picker based on bootstrap-datetime-picker.
* Requires jQuery, bootstrap-datetime-picker, moment.js
* Properties:
* - onDateChange: function to call on date change event of datepicker.
* - date: variable to pass which will be used as initial value.
*/
let Datetimepicker = class extends React.Component {
constructor(props) {
super(props);
this.handleDateChange = this.handleDateChange.bind(this);
this.clearField = this.clearField.bind(this);
this.markDateField = this.markDateField.bind(this);
this.state = {
invalid: false,
date: this.props.date,
dateLastValid: null,
};
}
componentDidMount() {
let funcDate = this.handleDateChange;
let datepicker = $(this.refs.datepicker).datetimepicker({
format: 'yyyy-mm-dd hh:ii:00',
autoclose: true,
todayBtn: true,
});
datepicker.on('changeDate', function(e) {
funcDate(e);
});
$(this.refs.datepicker_input).datetimepicker('remove');
this.markDateField();
}
componentWillUnmount() {
$(this.textInput).datetimepicker('remove');
}
handleDateChange(e) {
if (e.type === "changeDate") {
let event = new Event('input', { bubbles: true });
e.currentTarget.firstChild.dispatchEvent(event);
}
if (e.type === "input") {
this.setState({date: e.target.value});
if (parseDate(e.target.value)) {
this.setState({dateLastValid: e.target.value});
this.setState({invalid: false});
this.props.onDateChange(e.target.value, e.target.value.trim());
} else {
this.setState({invalid: true});
this.props.onDateChange(e.target.value, this.state.dateLastValid.trim());
}
}
}
clearField() {
$(this.refs.datepicker_input).val("");
let event = new Event('input', { bubbles: true });
this.refs.datepicker_input.dispatchEvent(event);
this.handleDateChange(event);
this.setState({invalid: false});
}
markDateField() {
let date = $(this.refs.datepicker_input).val()
.trim();
if (!parseDate(date)) {
this.setState({invalid: true});
} else {
this.setState({dateLastValid: date});
this.setState({invalid: false});
}
}
render() {
return (
<div ref="datepicker" className="input-group date input-append date form_datetime">
<input ref="datepicker_input" type="text" size="16"
className={"form-control bootstrap-datepicker " + (this.state.invalid ? "invalid" : "valid")}
readOnly value={this.state.date} onChange={this.handleDateChange} />
<span className="input-group-addon add-on"><i className="fa fa-calendar" /></span>
<span className="input-group-addon add-on" onClick={this.clearField}>
<i className="fa fa-remove" /></span>
</div>
);
}
}
/*
* 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 (
<div className="input-group">
<input type="text" className="form-control" value={this.props.username}
onChange={this.handleUsernameChange} />
</div>
);
}
}
let HostnamePicker = class extends React.Component {
constructor(props) {
super(props);
this.handleHostnameChange = this.handleHostnameChange.bind(this);
}
handleHostnameChange(e) {
this.props.onHostnameChange(e.target.value);
}
render() {
return (
<div className="input-group">
<input type="text" className="form-control" value={this.props.hostname}
onChange={this.handleHostnameChange} />
</div>
);
}
}
/*
* 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.
*/
let Recording = class extends React.Component {
constructor(props) {
super(props);
this.goBackToList = this.goBackToList.bind(this);
this.getHostname = this.getHostname.bind(this);
this.Hostname = this.Hostname.bind(this);
this.hostname = null;
}
goBackToList() {
if (cockpit.location.path[0]) {
cockpit.location.go([], cockpit.location.options);
} else {
cockpit.location.go('/');
}
}
getHostname() {
cockpit.spawn(["hostname"], { err: "ignore" })
.done(function(output) {
this.hostname = $.trim(output);
})
.fail(function(ex) {
console.log(ex);
});
}
Hostname(props) {
let style = {
display: "none"
};
if (this.hostname != null && this.hostname != props.hostname) {
style = {};
}
return (
<tr style={style}>
<td>{_("Hostname")}</td>
<td>{props.hostname}</td>
</tr>
);
}
componentWillMount() {
this.getHostname();
}
render() {
let r = this.props.recording;
if (r == null) {
return <span>Loading...</span>;
} else {
let player =
(<Player.Player
ref="player"
matchList={this.props.recording.matchList} />);
return (
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
<ol className="breadcrumb">
<li><a onClick={this.goBackToList}>Session Recording</a></li>
<li className="active">Session</li>
</ol>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="panel panel-default">
<div className="panel-heading">
<span>{_("Recording")}</span>
</div>
<div className="panel-body">
<table className="form-table-ct">
<tr>
<td>{_("ID")}</td>
<td>{r.id}</td>
</tr>
<this.Hostname hostname={r.hostname} />
<tr>
<td>{_("Boot ID")}</td>
<td>{r.boot_id}</td>
</tr>
<tr>
<td>{_("Session ID")}</td>
<td>{r.session_id}</td>
</tr>
<tr>
<td>{_("PID")}</td>
<td>{r.pid}</td>
</tr>
<tr>
<td>{_("Start")}</td>
<td>{formatDateTime(r.start)}</td>
</tr>
<tr>
<td>{_("End")}</td>
<td>{formatDateTime(r.end)}</td>
</tr>
<tr>
<td>{_("Duration")}</td>
<td>{formatDuration(r.end - r.start)}</td>
</tr>
<tr>
<td>{_("User")}</td>
<td>{r.user}</td>
</tr>
</table>
</div>
</div>
</div>
<div className="col-md-6 player-wrap">
{player}
</div>
</div>
</div>
);
}
}
};
/*
* A component representing a list of recordings.
* Properties:
* - list: an array with recording objects, as created by the View below
*/
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.getColumnTitles = this.getColumnTitles.bind(this);
this.getColumns = this.getColumns.bind(this);
this.state = {
sorting_field: "start",
sorting_asc: true,
};
}
drawSortDir() {
$('#sort_arrow').remove();
let type = this.state.sorting_asc ? "asc" : "desc";
let arrow = '<i id="sort_arrow" class="fa fa-sort-' + type + '" aria-hidden="true"></i>';
$(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;
}
/*
* Set the cockpit location to point to the specified recording.
*/
navigateToRecording(recording) {
cockpit.location.go([recording.id], cockpit.location.options);
}
componentDidUpdate() {
this.drawSortDir();
}
getColumnTitles() {
let columnTitles = [
(<div id="user" className="sort" onClick={this.handleColumnClick}><span>{_("User")}</span> <div
ref="user" className="sort-icon"></div></div>),
(<div id="start" className="sort" onClick={this.handleColumnClick}><span>{_("Start")}</span> <div
ref="start" className="sort-icon"></div></div>),
(<div id="end" className="sort" onClick={this.handleColumnClick}><span>{_("End")}</span> <div
ref="end" className="sort-icon"></div></div>),
(<div id="duration" className="sort" onClick={this.handleColumnClick}><span>{_("Duration")}</span> <div
ref="duration" className="sort-icon"></div></div>),
];
if (this.props.diff_hosts === true) {
columnTitles.push((<div id="hostname" className="sort" onClick={this.handleColumnClick}>
<span>{_("Hostname")}</span> <div ref="hostname" className="sort-icon"></div></div>));
}
return columnTitles;
}
getColumns(r) {
let columns = [r.user,
formatDateTime(r.start),
formatDateTime(r.end),
formatDuration(r.end - r.start)]
if (this.props.diff_hosts === true) {
columns.push(r.hostname);
}
return columns;
}
render() {
let columnTitles = this.getColumnTitles();
let list = this.getSortedList();
let rows = [];
for (let i = 0; i < list.length; i++) {
let r = list[i];
let columns = this.getColumns(r);
rows.push(<Listing.ListingRow
rowId={r.id}
columns={columns}
navigateToItem={this.navigateToRecording.bind(this, r)} />);
}
return (
<div>
<div className="content-header-extra">
<table className="form-table-ct">
<th>
<td className="top">
<label className="control-label" htmlFor="dateSince">Since</label>
</td>
<td>
<Datetimepicker onDateChange={this.props.onDateSinceChange}
date={this.props.dateSince} />
</td>
<td className="top">
<label className="control-label" htmlFor="dateUntil">Until</label>
</td>
<td>
<Datetimepicker onDateChange={this.props.onDateUntilChange}
date={this.props.dateUntil} />
</td>
<td className="top">
<label className="control-label" htmlFor="username">Username</label>
</td>
<td>
<UserPicker onUsernameChange={this.props.onUsernameChange}
username={this.props.username} />
</td>
<td className="top">
<label className="control-label" htmlFor="hostname">Hostname</label>
</td>
<td>
<HostnamePicker onHostnameChange={this.props.onHostnameChange}
hostname={this.props.hostname}/>
</td>
<td className="top">
<label className="control-label" htmlFor="config">Configuration</label>
</td>
<td className="top">
<a href="/cockpit/@localhost/session_recording/config.html" className="btn btn-default" data-toggle="modal">
<i className="fa fa-cog" aria-hidden="true" /></a>
</td>
</th>
</table>
</div>
<Listing.Listing title={_("Sessions")}
columnTitles={columnTitles}
emptyCaption={_("No recorded sessions")}
fullWidth={false}>
{rows}
</Listing.Listing>
</div>
);
}
};
/*
* 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.
*/
let View = class extends React.Component {
constructor(props) {
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);
this.handleUsernameChange = this.handleUsernameChange.bind(this);
this.handleHostnameChange = this.handleHostnameChange.bind(this);
/* Journalctl instance */
this.journalctl = null;
/* Recording ID journalctl instance is invoked with */
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: [],
/* ID of the recording to display, or null for all */
recordingID: cockpit.location.path[0] || null,
dateSince: cockpit.location.options.dateSince || null,
dateSinceLastValid: null,
dateUntil: cockpit.location.options.dateUntil || null,
dateUntilLastValid: null,
/* value to filter recordings by username */
username: cockpit.location.options.username || null,
hostname: cockpit.location.options.hostname || null,
error_tlog_uid: false,
diff_hosts: false,
}
}
/*
* 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,
dateSince: cockpit.location.options.dateSince || null,
dateUntil: cockpit.location.options.dateUntil || null,
username: cockpit.location.options.username || null,
hostname: cockpit.location.options.hostname || null,
});
}
/*
* Ingest journal entries sent by journalctl.
*/
journalctlIngest(entryList) {
let recordingList = this.state.recordingList.slice();
let i;
let j;
let hostname;
if (entryList[0]) {
if (entryList[0]["_HOSTNAME"]) {
hostname = entryList[0]["_HOSTNAME"];
}
}
for (i = 0; i < entryList.length; i++) {
let e = entryList[i];
let id = e['TLOG_REC'];
/* Skip entries with missing recording ID */
if (id === undefined) {
continue;
}
let ts = Math.floor(
parseInt(e["__REALTIME_TIMESTAMP"], 10) /
1000);
let r = this.recordingMap[id];
/* If no recording found */
if (r === undefined) {
/* Create new recording */
if (hostname != e["_HOSTNAME"]) {
this.setState({diff_hosts: true});
}
r = {id: id,
matchList: ["_UID=" + this.uid,
"TLOG_REC=" + 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,
duration: 0};
/* 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;
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;
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() {
let matches = ["_UID=" + this.uid];
if (this.state.username) {
matches.push("TLOG_USER=" + this.state.username);
}
if (this.state.hostname && this.state.hostname != null &&
this.state.hostname != "") {
matches.push("_HOSTNAME=" + this.state.hostname);
}
let options = {follow: true, count: "all"};
if (this.state.dateSinceLastValid) {
options['since'] = this.state.dateSinceLastValid;
}
if (this.state.dateUntil) {
options['until'] = this.state.dateUntilLastValid;
}
if (this.state.recordingID !== null) {
matches.push("TLOG_REC=" + this.state.recordingID);
}
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;
}
/*
* 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, last_valid) {
this.setState({dateSinceLastValid: last_valid});
cockpit.location.go([], $.extend(cockpit.location.options, { dateSince: date }));
}
handleDateUntilChange(date, last_valid) {
this.setState({dateUntilLastValid: last_valid});
cockpit.location.go([], $.extend(cockpit.location.options, { dateUntil: date }));
}
handleUsernameChange(username) {
cockpit.location.go([], $.extend(cockpit.location.options, { username: username }));
}
handleHostnameChange(hostname) {
cockpit.location.go([], $.extend(cockpit.location.options, { hostname: hostname }));
}
componentDidMount() {
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});
});
let dateSince = parseDate(this.state.dateSince);
if (dateSince && dateSince != true) {
this.setState({dateSinceLastValid: dateSince});
}
let dateUntil = parseDate(this.state.dateUntil);
if (dateUntil && dateUntil != true) {
this.setState({dateUntilLastValid: dateUntil});
}
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();
}
if (this.state.dateSinceLastValid != prevState.dateSinceLastValid ||
this.state.dateUntilLastValid != prevState.dateUntilLastValid ||
this.state.username != prevState.username ||
this.state.hostname != prevState.hostname
) {
this.clearRecordings();
this.journalctlRestart();
}
}
render() {
if (this.state.error_tlog_uid === true) {
return (
<div className="container-fluid">
Error getting tlog UID from system.
</div>
);
}
if (this.state.recordingID === null) {
return (
<RecordingList
onDateSinceChange={this.handleDateSinceChange} dateSince={this.state.dateSince}
onDateUntilChange={this.handleDateUntilChange} dateUntil={this.state.dateUntil}
onUsernameChange={this.handleUsernameChange} username={this.state.username}
onHostnameChange={this.handleHostnameChange} hostname={this.state.hostname}
list={this.state.recordingList} diff_hosts={this.state.diff_hosts} />
);
} else {
return (
<Recording recording={this.recordingMap[this.state.recordingID]} />
);
}
}
};
React.render(<View />, document.getElementById('view'));
}());