/*
* This file is part of Cockpit.
*
* Copyright (C) 2017 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see .
*/
import React from "react";
import {
Breadcrumb, BreadcrumbItem,
Bullseye,
Button,
Card,
CardBody,
DataList,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateVariant,
ExpandableSection,
Page, PageSection, PageSectionVariants,
Spinner,
TextInput,
Toolbar,
ToolbarContent,
ToolbarItem,
ToolbarGroup, EmptyStateHeader,
} from "@patternfly/react-core";
import { sortable, SortByDirection } from '@patternfly/react-table';
import { TableHeader, TableBody, Table as TableDeprecated } from '@patternfly/react-table/deprecated';
import {
CogIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
PlusIcon,
SearchIcon
} from "@patternfly/react-icons";
import cockpit from 'cockpit';
import { global_danger_color_200 } from "@patternfly/react-tokens";
import { debounce } from 'throttle-debounce';
import { journal } from 'journal';
const $ = require("jquery");
const _ = cockpit.gettext;
const Player = require("./player.jsx");
const Config = require("./config.jsx");
/*
* Convert a number to integer number string and pad with zeroes to
* specified width.
*/
const padInt = function (n, w) {
const i = Math.floor(n);
const a = Math.abs(i);
let s = a.toString();
for (w -= s.length; w > 0; w--) {
s = '0' + s;
}
return ((i < 0) ? '-' : '') + s;
};
/*
* Format date and time for a number of milliseconds since Epoch.
* YYYY-MM-DD HH:mm:ss
*/
const formatDateTime = function (ms) {
/* Convert local timezone offset */
const t = new Date(ms);
const z = t.getTimezoneOffset() * 60 * 1000;
let tLocal = t - z;
tLocal = new Date(tLocal);
let iso = tLocal.toISOString();
/* cleanup ISO format */
iso = iso.slice(0, 19);
iso = iso.replace('T', ' ');
return iso;
};
const formatUTC = function(date) {
let iso = null;
try {
iso = new Date(date).toISOString();
iso = iso.slice(0, 19);
iso = iso.replace('T', ' ') + " UTC";
} catch (error) {
iso = "";
}
return iso;
};
/*
* Format a time interval from a number of milliseconds.
*/
const formatDuration = function (ms) {
let v = Math.floor(ms / 1000);
const s = Math.floor(v % 60);
v = Math.floor(v / 60);
const m = Math.floor(v % 60);
v = Math.floor(v / 60);
const h = Math.floor(v % 24);
const d = Math.floor(v / 24);
let str = '';
if (d > 0) {
str += d + ' ' + _("days") + ' ';
}
if (h > 0 || str.length > 0) {
str += padInt(h, 2) + ':';
}
str += padInt(m, 2) + ':' + padInt(s, 2);
return (ms < 0 ? '-' : '') + str;
};
function LogElement(props) {
const entry = props.entry;
const start = props.start;
const cursor = entry.__CURSOR;
const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000);
const timeClick = (_e) => {
const ts = entry_timestamp - start;
if (ts > 0) {
props.onJumpToTs(ts);
} else {
props.onJumpToTs(0);
}
};
const messageClick = () => {
const url = '/system/logs#/' + cursor + '?parent_options={}';
const win = window.open(url, '_blank');
win.focus();
};
const cells = (
{entry.MESSAGE}
]}
/>
);
return (
{cells}
);
}
function LogsView(props) {
const { entries, start, end } = props;
const rows = entries.map((entry) =>
);
return (
);
}
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.handleLoadLater = this.handleLoadLater.bind(this);
this.loadForTs = this.loadForTs.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: [],
};
}
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 });
}
}
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;
}
const matches = [];
if (this.hostname) {
matches.push("_HOSTNAME=" + this.hostname);
}
let start = null;
let end = null;
start = formatDateTime(this.start);
end = formatDateTime(this.end);
const options = {
since: start,
until: end,
follow: false,
count: "all",
merge: true,
utc: 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);
});
}
}
handleLoadLater() {
this.start = this.end;
this.end = this.end + 3600;
this.getLogs();
}
loadForTs(ts) {
this.end = this.start + ts;
this.getLogs();
}
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() {
if (this.journalCtl) {
this.journalCtl.stop();
}
this.setState({
serverTimeOffset: null,
cursor: null,
after: null,
entries: [],
});
}
render() {
const r = this.props.recording;
if (r == null) {
return (
{_("Loading...")}>} headingLevel="h2" />
);
} else {
return (
<>
}
onClick={this.handleLoadLater}
>
{_("Load later entries")}
>
);
}
}
}
/*
* 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.handleGoBackToList = this.handleGoBackToList.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.playerRef = React.createRef();
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 });
});
}
handleGoBackToList() {
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() {
const r = this.props.recording;
if (r == null) {
return (
{_("Loading...")}>} headingLevel="h2" />
);
} else {
return (
{_("Session Recording")}
{_("Current recording")}
}
>
);
}
}
}
/*
* 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.handleOnSort = this.handleOnSort.bind(this);
this.handleRowClick = this.handleRowClick.bind(this);
this.state = {
sortBy: {
index: 1,
direction: SortByDirection.asc
}
};
}
handleOnSort(_event, index, direction) {
this.setState({
sortBy: {
index,
direction
},
});
}
handleRowClick(_event, row) {
cockpit.location.go([row.id], cockpit.location.options);
}
render() {
const { sortBy } = this.state;
const { index, direction } = sortBy;
// generate columns
const titles = ["User", "Start", "End", "Duration"];
if (this.props.diff_hosts === true)
titles.push("Hostname");
const columnTitles = titles.map(title => ({
title: _(title),
transforms: [sortable]
}));
// sort rows
let rows = this.props.list.map(rec => {
const cells = [
rec.user,
formatDateTime(rec.start),
formatDateTime(rec.end),
formatDuration(rec.end - rec.start),
];
if (this.props.diff_hosts === true)
cells.push(rec.hostname);
return {
id: rec.id,
cells,
};
}).sort((a, b) => a.cells[index].localeCompare(b.cells[index]));
rows = direction === SortByDirection.asc ? rows : rows.reverse();
return (
<>
{!rows.length &&
{_("No recordings found")}>} icon={} headingLevel="h2" />
{_("No recordings matched the filter criteria.")}
}
>
);
}
}
/*
* 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.
*/
export default 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.handleOpenConfig = this.handleOpenConfig.bind(this);
/* Journalctl instance */
this.journalctl = null;
/* Recording ID journalctl instance is invoked with */
this.journalctlRecordingID = null;
/* Recording ID -> data map */
this.recordingMap = {};
const path = cockpit.location.path[0];
this.state = {
/* List of recordings in start order */
recordingList: [],
/* ID of the recording to display, or null for all */
recordingID: path === "config" ? null : path || 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_user: false,
diff_hosts: false,
/* if config is open */
config: path === "config",
};
}
/*
* 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() {
const path = cockpit.location.path[0];
if (path === "config")
this.setState({ config: true });
else
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 || "",
config: false
});
}
/*
* Ingest journal entries sent by journalctl.
*/
journalctlIngest(entryList) {
const 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++) {
const e = entryList[i];
const id = e.TLOG_REC;
/* Skip entries with missing recording ID */
if (id === undefined) {
continue;
}
const 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,
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 });
}
/*
* Start journalctl, retrieving entries for the current recording ID.
* Assumes journalctl is not running.
*/
journalctlStart() {
const 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);
}
const 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: [] });
}
throttleJournalRestart = debounce(300, () => {
this.clearRecordings();
this.journalctlRestart();
});
handleInputChange(name, value) {
const state = {};
state[name] = value;
this.setState(state);
cockpit.location.go([], $.extend(cockpit.location.options, state));
}
handleOpenConfig() {
cockpit.location.go("/config");
}
componentDidMount() {
const proc = cockpit.spawn(["getent", "passwd", "tlog"]);
proc.stream((data) => {
this.journalctlStart();
proc.close();
});
proc.fail(() => {
this.setState({ error_tlog_user: 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.throttleJournalRestart();
}
}
render() {
if (this.state.config === true) {
return ;
} else if (this.state.error_tlog_user === true) {
return (
{_("Error")}>}
icon={
} headingLevel="h2"
/>
{_("Unable to retrieve tlog user from system.")}
);
} else if (this.state.recordingID === null) {
const toolbar = (
{_("Since")}
this.handleInputChange("date_since", value)}
/>
{_("Until")}
this.handleInputChange("date_until", value)}
/>
{_("Search")}
this.handleInputChange("search", value)}
/>
{_("Username")}
this.handleInputChange("username", value)}
/>
{this.state.diff_hosts === true &&
{_("Hostname")}
this.handleInputChange("hostname", value)}
/>
}
);
return (
{toolbar}
);
} else {
return (
);
}
}
}