998 lines
34 KiB
JavaScript
998 lines
34 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/>.
|
|
*/
|
|
"use strict";
|
|
|
|
import React from "react";
|
|
import ReactDOM from "react-dom";
|
|
|
|
let $ = require("jquery");
|
|
let cockpit = require("cockpit");
|
|
let _ = cockpit.gettext;
|
|
let moment = require("moment");
|
|
let Journal = require("journal");
|
|
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) {
|
|
return moment(ms).format("YYYY-MM-DD HH:mm:ss");
|
|
};
|
|
|
|
let formatDateTimeOffset = function (ms, offset) {
|
|
return moment(ms).utcOffset(offset)
|
|
.format("YYYY-MM-DD HH:mm:ss");
|
|
};
|
|
|
|
let formatUTC = function(date) {
|
|
return moment(date).utc()
|
|
.format("YYYY-MM-DD HH:mm:ss") + " UTC";
|
|
};
|
|
|
|
/*
|
|
* 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:
|
|
* - onChange: function to call on date change event of datepicker.
|
|
* - value: variable to pass which will be used as initial value.
|
|
*/
|
|
class Datetimepicker extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.handleDateChange = this.handleDateChange.bind(this);
|
|
this.clearField = this.clearField.bind(this);
|
|
this.state = {
|
|
invalid: false,
|
|
date: this.props.value,
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
$(this.refs.datepicker).datetimepicker({
|
|
format: 'yyyy-mm-dd hh:ii:00',
|
|
autoclose: true,
|
|
todayBtn: true,
|
|
})
|
|
.on('changeDate', this.handleDateChange);
|
|
// remove datepicker from input, so it only works by button press
|
|
$(this.refs.datepicker_input).datetimepicker('remove');
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
$(this.refs.datepicker).datetimepicker('remove');
|
|
}
|
|
|
|
handleDateChange() {
|
|
const date = $(this.refs.datepicker_input).val()
|
|
.trim();
|
|
this.setState({invalid: false, date: date});
|
|
if (!parseDate(date)) {
|
|
this.setState({invalid: true});
|
|
} else {
|
|
this.props.onChange(date);
|
|
}
|
|
}
|
|
|
|
clearField() {
|
|
const date = "";
|
|
this.props.onChange(date);
|
|
this.setState({date: date, invalid: false});
|
|
$(this.refs.datepicker_input).val("");
|
|
}
|
|
|
|
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")}
|
|
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>
|
|
);
|
|
}
|
|
}
|
|
|
|
function LogElement(props) {
|
|
const entry = props.entry;
|
|
const start = props.start;
|
|
const end = props.end;
|
|
const cursor = entry.__CURSOR;
|
|
const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000);
|
|
|
|
const timeClick = function(e) {
|
|
const ts = entry_timestamp - start;
|
|
if (ts > 0) {
|
|
props.jumpToTs(ts);
|
|
} else {
|
|
props.jumpToTs(0);
|
|
}
|
|
};
|
|
const messageClick = () => {
|
|
const url = '/system/logs#/' + cursor + '?parent_options={}';
|
|
const win = window.open(url, '_blank');
|
|
win.focus();
|
|
};
|
|
|
|
let className = 'cockpit-logline';
|
|
if (start < entry_timestamp && end > entry_timestamp) {
|
|
className = 'cockpit-logline highlighted';
|
|
}
|
|
|
|
return (
|
|
<div className={className} data-cursor={cursor} key={cursor}>
|
|
<div className="cockpit-log-warning">
|
|
<i className="fa fa-exclamation-triangle" />
|
|
</div>
|
|
<div className="logs-view-log-time" onClick={timeClick}>{formatDateTime(entry_timestamp)}</div>
|
|
<span className="cockpit-log-message" onClick={messageClick}>{entry.MESSAGE}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LogsView(props) {
|
|
const entries = props.entries;
|
|
const start = props.start;
|
|
const end = props.end;
|
|
const rows = entries.map((entry) =>
|
|
<LogElement key={entry.__CURSOR} entry={entry} start={start} end={end} jumpToTs={props.jumpToTs} />
|
|
);
|
|
return (
|
|
<div className="panel panel-default cockpit-log-panel" id="logs-view">
|
|
{rows}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
class Logs extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.journalctlError = this.journalctlError.bind(this);
|
|
this.journalctlIngest = this.journalctlIngest.bind(this);
|
|
this.journalctlPrepend = this.journalctlPrepend.bind(this);
|
|
this.getLogs = this.getLogs.bind(this);
|
|
this.loadLater = this.loadLater.bind(this);
|
|
this.loadForTs = this.loadForTs.bind(this);
|
|
this.getServerTimeOffset = this.getServerTimeOffset.bind(this);
|
|
this.journalCtl = null;
|
|
this.entries = [];
|
|
this.start = null;
|
|
this.end = null;
|
|
this.hostname = null;
|
|
this.state = {
|
|
serverTimeOffset: null,
|
|
cursor: null,
|
|
after: null,
|
|
entries: [],
|
|
};
|
|
}
|
|
|
|
getServerTimeOffset() {
|
|
cockpit.spawn(["date", "+%s:%:z"], { err: "message" })
|
|
.done((data) => {
|
|
this.setState({serverTimeOffset: data.slice(data.indexOf(":") + 1)});
|
|
})
|
|
.fail((ex) => {
|
|
console.log("Couldn't calculate server time offset: " + cockpit.message(ex));
|
|
});
|
|
}
|
|
|
|
scrollToTop() {
|
|
const logs_view = document.getElementById("logs-view");
|
|
logs_view.scrollTop = 0;
|
|
}
|
|
|
|
scrollToBottom() {
|
|
const logs_view = document.getElementById("logs-view");
|
|
logs_view.scrollTop = logs_view.scrollHeight;
|
|
}
|
|
|
|
journalctlError(error) {
|
|
console.warn(cockpit.message(error));
|
|
}
|
|
|
|
journalctlIngest(entryList) {
|
|
if (entryList.length > 0) {
|
|
this.entries.push(...entryList);
|
|
const after = this.entries[this.entries.length - 1].__CURSOR;
|
|
this.setState({entries: this.entries, after: after});
|
|
this.scrollToBottom();
|
|
}
|
|
}
|
|
|
|
journalctlPrepend(entryList) {
|
|
entryList.push(...this.entries);
|
|
this.setState({entries: this.entries});
|
|
}
|
|
|
|
getLogs() {
|
|
if (this.start != null && this.end != null) {
|
|
if (this.journalCtl != null) {
|
|
this.journalCtl.stop();
|
|
this.journalCtl = null;
|
|
}
|
|
|
|
let matches = [];
|
|
if (this.hostname) {
|
|
matches.push("_HOSTNAME=" + this.hostname);
|
|
}
|
|
|
|
let start = null;
|
|
let end = null;
|
|
|
|
if (this.state.serverTimeOffset != null) {
|
|
start = formatDateTimeOffset(this.start, this.state.serverTimeOffset);
|
|
end = formatDateTimeOffset(this.end, this.state.serverTimeOffset);
|
|
} else {
|
|
start = formatDateTime(this.start);
|
|
end = formatDateTime(this.end);
|
|
}
|
|
|
|
let options = {
|
|
since: start,
|
|
until: end,
|
|
follow: false,
|
|
count: "all",
|
|
merge: true,
|
|
};
|
|
|
|
if (this.state.after != null) {
|
|
options["after"] = this.state.after;
|
|
delete options.since;
|
|
}
|
|
|
|
const self = this;
|
|
this.journalCtl = Journal.journalctl(matches, options)
|
|
.fail(this.journalctlError)
|
|
.done(function(data) {
|
|
self.journalctlIngest(data);
|
|
});
|
|
}
|
|
}
|
|
|
|
loadLater() {
|
|
this.start = this.end;
|
|
this.end = this.end + 3600;
|
|
this.getLogs();
|
|
}
|
|
|
|
loadForTs(ts) {
|
|
this.end = this.start + ts;
|
|
this.getLogs();
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.getServerTimeOffset();
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
if (this.props.recording) {
|
|
if (this.start === null && this.end === null) {
|
|
this.end = this.props.recording.start + 3600;
|
|
this.start = this.props.recording.start;
|
|
}
|
|
if (this.props.recording.hostname) {
|
|
this.hostname = this.props.recording.hostname;
|
|
}
|
|
this.getLogs();
|
|
}
|
|
if (this.props.curTs) {
|
|
const ts = this.props.curTs;
|
|
this.loadForTs(ts);
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.journalCtl.stop();
|
|
this.setState({
|
|
serverTimeOffset: null,
|
|
cursor: null,
|
|
after: null,
|
|
entries: [],
|
|
});
|
|
}
|
|
|
|
render() {
|
|
let r = this.props.recording;
|
|
if (r == null) {
|
|
return <span>Loading...</span>;
|
|
} else {
|
|
return (
|
|
<div className="panel panel-default">
|
|
<div className="panel-heading">
|
|
<span>{_("Logs")}</span>
|
|
<button className="btn btn-default" style={{"float":"right"}} onClick={this.loadLater}>{_("Load later entries")}</button>
|
|
</div>
|
|
<LogsView entries={this.state.entries} start={this.props.recording.start}
|
|
end={this.props.recording.end} jumpToTs={this.props.jumpToTs} />
|
|
<div className="panel-heading" />
|
|
</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.
|
|
*/
|
|
class Recording extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.goBackToList = this.goBackToList.bind(this);
|
|
this.handleTsChange = this.handleTsChange.bind(this);
|
|
this.handleLogTsChange = this.handleLogTsChange.bind(this);
|
|
this.handleLogsClick = this.handleLogsClick.bind(this);
|
|
this.handleLogsReset = this.handleLogsReset.bind(this);
|
|
this.state = {
|
|
curTs: null,
|
|
logsTs: null,
|
|
logsEnabled: false,
|
|
};
|
|
}
|
|
|
|
handleTsChange(ts) {
|
|
this.setState({curTs: ts});
|
|
}
|
|
|
|
handleLogTsChange(ts) {
|
|
this.setState({logsTs: ts});
|
|
}
|
|
|
|
handleLogsClick() {
|
|
this.setState({logsEnabled: !this.state.logsEnabled});
|
|
}
|
|
|
|
handleLogsReset() {
|
|
this.setState({logsEnabled: false}, () => {
|
|
this.setState({logsEnabled: true});
|
|
});
|
|
}
|
|
|
|
goBackToList() {
|
|
if (cockpit.location.path[0]) {
|
|
if ("search_rec" in cockpit.location.options) {
|
|
delete cockpit.location.options.search_rec;
|
|
}
|
|
cockpit.location.go([], cockpit.location.options);
|
|
} else {
|
|
cockpit.location.go('/');
|
|
}
|
|
}
|
|
|
|
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}
|
|
logsTs={this.logsTs}
|
|
search={this.props.search}
|
|
onTsChange={this.handleTsChange}
|
|
recording={r}
|
|
logsEnabled={this.state.logsEnabled}
|
|
onRewindStart={this.handleLogsReset} />);
|
|
|
|
return (
|
|
<React.Fragment>
|
|
<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>
|
|
{player}
|
|
</div>
|
|
<div className="row">
|
|
<div className="col-md-12">
|
|
<button className="btn btn-default" style={{"float":"left"}} onClick={this.handleLogsClick}>{_("Logs View")}</button>
|
|
</div>
|
|
</div>
|
|
{this.state.logsEnabled === true &&
|
|
<div className="row">
|
|
<div className="col-md-12">
|
|
<Logs recording={this.props.recording} curTs={this.state.curTs} jumpToTs={this.handleLogTsChange} />
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A component representing a list of recordings.
|
|
* Properties:
|
|
* - list: an array with recording objects, as created by the View below
|
|
*/
|
|
class RecordingList 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" />';
|
|
$(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: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
getSortedList() {
|
|
let field = this.state.sorting_field;
|
|
let asc = this.state.sorting_asc;
|
|
let list = this.props.list.slice();
|
|
let isNumeric;
|
|
|
|
if (field === "start" || field === "end" || field === "duration") {
|
|
isNumeric = true;
|
|
}
|
|
|
|
if (isNumeric) {
|
|
list.sort((a, b) => a[field] - b[field]);
|
|
} else {
|
|
list.sort((a, b) => (a[field] > b[field]) ? 1 : -1);
|
|
}
|
|
|
|
if (!asc) {
|
|
list.reverse();
|
|
}
|
|
|
|
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 id="start" className="sort" onClick={this.handleColumnClick}><span>{_("Start")}</span> <div
|
|
ref="start" className="sort-icon" /></div>),
|
|
(<div id="end" className="sort" onClick={this.handleColumnClick}><span>{_("End")}</span> <div
|
|
ref="end" className="sort-icon" /></div>),
|
|
(<div id="duration" className="sort" onClick={this.handleColumnClick}><span>{_("Duration")}</span> <div
|
|
ref="duration" className="sort-icon" /></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>));
|
|
}
|
|
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
|
|
key={r.id}
|
|
rowId={r.id}
|
|
columns={columns}
|
|
navigateToItem={this.navigateToRecording.bind(this, r)} />);
|
|
}
|
|
return (
|
|
<Listing.Listing title={_("Sessions")}
|
|
columnTitles={columnTitles}
|
|
emptyCaption={_("No recorded sessions")}
|
|
fullWidth={false}>
|
|
{rows}
|
|
</Listing.Listing>
|
|
);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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.
|
|
*/
|
|
class View extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.onLocationChanged = this.onLocationChanged.bind(this);
|
|
this.journalctlIngest = this.journalctlIngest.bind(this);
|
|
this.handleInputChange = this.handleInputChange.bind(this);
|
|
this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
|
|
this.openConfig = this.openConfig.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,
|
|
/* filter values start */
|
|
date_since: cockpit.location.options.date_since || "",
|
|
date_until: cockpit.location.options.date_until || "",
|
|
username: cockpit.location.options.username || "",
|
|
hostname: cockpit.location.options.hostname || "",
|
|
search: cockpit.location.options.search || "",
|
|
/* filter values end */
|
|
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,
|
|
date_since: cockpit.location.options.date_since || "",
|
|
date_until: cockpit.location.options.date_until || "",
|
|
username: cockpit.location.options.username || "",
|
|
hostname: cockpit.location.options.hostname || "",
|
|
search: cockpit.location.options.search || "",
|
|
});
|
|
}
|
|
|
|
/*
|
|
* 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: ["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,
|
|
hostname: e["_HOSTNAME"],
|
|
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 = ["_COMM=tlog-rec",
|
|
/* Strings longer than TASK_COMM_LEN (16) characters
|
|
* are truncated (man proc) */
|
|
"_COMM=tlog-rec-sessio"];
|
|
|
|
if (this.state.username && this.state.username !== "") {
|
|
matches.push("TLOG_USER=" + this.state.username);
|
|
}
|
|
if (this.state.hostname && this.state.hostname !== "") {
|
|
matches.push("_HOSTNAME=" + this.state.hostname);
|
|
}
|
|
|
|
let options = {follow: false, count: "all", merge: true};
|
|
|
|
if (this.state.date_since && this.state.date_since !== "") {
|
|
options['since'] = formatUTC(this.state.date_since);
|
|
}
|
|
|
|
if (this.state.date_until && this.state.date_until !== "") {
|
|
options['until'] = formatUTC(this.state.date_until);
|
|
}
|
|
|
|
if (this.state.search && this.state.search !== "" && this.state.recordingID === null) {
|
|
options["grep"] = this.state.search;
|
|
}
|
|
|
|
if (this.state.recordingID !== null) {
|
|
delete options["grep"];
|
|
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: []});
|
|
}
|
|
|
|
handleInputChange(event) {
|
|
const name = event.target.name;
|
|
const value = event.target.value;
|
|
let state = {};
|
|
state[name] = value;
|
|
this.setState(state);
|
|
cockpit.location.go([], $.extend(cockpit.location.options, state));
|
|
}
|
|
|
|
handleDateSinceChange(date) {
|
|
cockpit.location.go([], $.extend(cockpit.location.options, {date_since: date}));
|
|
}
|
|
|
|
handleDateUntilChange(date) {
|
|
cockpit.location.go([], $.extend(cockpit.location.options, {date_until: date}));
|
|
}
|
|
|
|
openConfig() {
|
|
cockpit.jump(['session-recording/config']);
|
|
}
|
|
|
|
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});
|
|
});
|
|
|
|
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.date_since !== prevState.date_since ||
|
|
this.state.date_until !== prevState.date_until ||
|
|
this.state.username !== prevState.username ||
|
|
this.state.hostname !== prevState.hostname ||
|
|
this.state.search !== prevState.search
|
|
) {
|
|
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 (
|
|
<React.Fragment>
|
|
<div className="content-header-extra">
|
|
<table className="form-table-ct">
|
|
<thead>
|
|
<tr>
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="date_since">{_("Since")}</label>
|
|
</td>
|
|
<td>
|
|
<Datetimepicker value={this.state.date_since} onChange={this.handleDateSinceChange} />
|
|
</td>
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="date_until">{_("Until")}</label>
|
|
</td>
|
|
<td>
|
|
<Datetimepicker value={this.state.date_until} onChange={this.handleDateUntilChange} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="search">Search</label>
|
|
</td>
|
|
<td>
|
|
<div className="input-group">
|
|
<input type="text" className="form-control" name="search" value={this.state.search}
|
|
onChange={this.handleInputChange} />
|
|
</div>
|
|
</td>
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="username">Username</label>
|
|
</td>
|
|
<td>
|
|
<div className="input-group">
|
|
<input type="text" className="form-control" name="username" value={this.state.username}
|
|
onChange={this.handleInputChange} />
|
|
</div>
|
|
</td>
|
|
{this.state.diff_hosts === true &&
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="hostname">{_("Hostname")}</label>
|
|
</td>
|
|
}
|
|
{this.state.diff_hosts === true &&
|
|
<td>
|
|
<div className="input-group">
|
|
<input type="text" className="form-control" name="hostname" value={this.state.hostname}
|
|
onChange={this.handleInputChange} />
|
|
</div>
|
|
</td>
|
|
}
|
|
<td className="top">
|
|
<label className="control-label" htmlFor="config">{_("Configuration")}</label>
|
|
</td>
|
|
<td className="top">
|
|
<button className="btn btn-default" onClick={this.openConfig}><i className="fa fa-cog" aria-hidden="true" /></button>
|
|
</td>
|
|
</tr>
|
|
</thead>
|
|
</table>
|
|
</div>
|
|
<RecordingList
|
|
date_since={this.state.date_since}
|
|
date_until={this.state.date_until}
|
|
username={this.state.username}
|
|
hostname={this.state.hostname}
|
|
list={this.state.recordingList}
|
|
diff_hosts={this.state.diff_hosts} />
|
|
</React.Fragment>
|
|
);
|
|
} else {
|
|
return (
|
|
<React.Fragment>
|
|
<Recording recording={this.recordingMap[this.state.recordingID]} search={this.state.search} />
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
ReactDOM.render(<View />, document.getElementById('view'));
|